diff --git a/Cargo.toml b/Cargo.toml index 066cad69e..b59ed4a34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -148,5 +148,9 @@ path = "helpers/generate_openapi.rs" name = "verify_eip712" path = "helpers/verify_eip712.rs" +[[example]] +name = "sign_soroban_auth_entry" +path = "helpers/sign_soroban_auth_entry.rs" + [lib] path = "src/lib.rs" diff --git a/docs/guides/stellar-sponsored-transactions-guide.mdx b/docs/guides/stellar-sponsored-transactions-guide.mdx index 17723ddee..b8327640d 100644 --- a/docs/guides/stellar-sponsored-transactions-guide.mdx +++ b/docs/guides/stellar-sponsored-transactions-guide.mdx @@ -70,10 +70,12 @@ First, configure a Stellar relayer in your `config.json`: ### 2. Policy Configuration Explained #### `fee_payment_strategy` + - **`relayer`**: Relayer pays all network fees. Users pay fees in tokens. - **`user`**: User must include fee payment in the transaction. #### `allowed_tokens` + List of tokens that can be used for fee payment. Each token can have: - **`asset`**: Asset identifier in format `"native"` or `"CODE:ISSUER"` (e.g., `"USDC:GA5Z..."`) @@ -85,6 +87,7 @@ List of tokens that can be used for fee payment. Each token can have: - `retain_min_amount`: Minimum amount to retain after swap #### `swap_config` (Global) + Configuration for converting collected tokens back to XLM: - **`strategies`**: DEX strategies to use (currently supports `order-book`) @@ -92,9 +95,11 @@ Configuration for converting collected tokens back to XLM: - **`min_balance_threshold`**: Minimum XLM balance (in stroops) before triggering swaps #### `slippage_percentage` + Default slippage tolerance for token conversions (default: 1.0%) #### `fee_margin_percentage` + Additional fee margin added to estimated fees to account for price fluctuations (default: 10.0%) ### 3. Enabling Trustlines @@ -103,7 +108,6 @@ Before users can pay fees in tokens, the relayer account must establish trustlin For a complete example of how to create trustlines, see the [Relayer SDK example](https://github.com/OpenZeppelin/openzeppelin-relayer-sdk/tree/main/examples/relayer/stellar/sponsored/create-trustline.ts). - ## Using the API The sponsored transaction flow consists of four steps: **Quote**, **Build**, **Sign**, and **Send**. @@ -226,7 +230,7 @@ If the user's address is connected with another relayer configured in **relayer **Endpoint:** `POST /api/v1/relayers/{relayer_id}/sign-transaction` -``` +```` #### Option 3: Custom Signing Solution @@ -251,7 +255,7 @@ Submit the signed transaction to the relayer. The relayer will: "network": "testnet", "fee_bump": true } -``` +```` **Response:** @@ -304,6 +308,7 @@ When building transactions from operations, use the following format: ``` Supported operation types: + - `payment`: Standard payment operation - `createAccount`: Account creation - `changeTrust`: Trustline management @@ -331,7 +336,6 @@ Supported operation types: - `extendFootprintTtl`: Extend footprint TTL - `restoreFootprint`: Restore footprint - ## Best Practices 1. **Always Quote First**: Get a fee quote before building to show users the expected cost @@ -342,6 +346,287 @@ Supported operation types: 6. **Use Fee Margins**: The `fee_margin_percentage` helps account for price fluctuations 7. **Monitor Trustlines**: Ensure trustlines are established before users attempt transactions +## Soroban Gas Abstraction + +### Overview + +Soroban Gas Abstraction extends the sponsored transaction concept to Soroban smart contracts. Instead of requiring users to hold XLM for contract invocation fees, users can pay in any Soroban token (e.g., a USDC Soroban token contract). This is powered by a **FeeForwarder smart contract** that wraps the user's contract call with fee collection logic. + +**How it differs from Classic Stellar Sponsored Transactions:** + +| Aspect | Classic Stellar | Soroban Gas Abstraction | +| -------------------- | ----------------------------------------------- | -------------------------------------------------------- | +| **Transaction type** | Classic operations (payments, trustlines, etc.) | Soroban `InvokeHostFunction` operations | +| **Fee mechanism** | Fee-bump transaction wrapper | FeeForwarder smart contract | +| **Fee token format** | Credit assets (`CODE:ISSUER`) | Soroban token contracts (`C...` address) | +| **Authorization** | Standard transaction signing | User signs a `SorobanAuthorizationEntry` | +| **Submission** | Relayer wraps in fee-bump envelope | Relayer injects signed auth entries and submits directly | + +### Prerequisites + +- A running OpenZeppelin Relayer instance +- A Stellar relayer configured with `"fee_payment_strategy": "user"` +- A deployed **FeeForwarder contract** on the target network. [Check the OpenZeppelin contract here](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/fee-abstraction) +- The `STELLAR_TESTNET_FEE_FORWARDER_ADDRESS` or `STELLAR_MAINNET_FEE_FORWARDER_ADDRESS` environment variable set to the FeeForwarder contract address +- Allowed tokens configured with Soroban token contract addresses (`C...` format) +- Sufficient XLM balance in the relayer account for network fees + +### Configuration + +Configure the relayer with Soroban token contracts in the `allowed_tokens` list. Note that Soroban tokens use contract addresses (`C...` format) rather than the `CODE:ISSUER` format used for classic Stellar assets. + +```json +{ + "relayers": [ + { + "id": "stellar-soroban-gas", + "name": "Stellar Soroban Gas Abstraction", + "network": "testnet", + "paused": false, + "signer_id": "local-signer", + "network_type": "stellar", + "policies": { + "fee_payment_strategy": "user", + "fee_margin_percentage": 5, + "allowed_tokens": [ + { + "asset": "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + "max_allowed_fee": 1000000000, + "swap_config": { + "slippage_percentage": 0.5, + "min_amount": 10000000, + "max_amount": 1000000000, + "retain_min_amount": 10000000 + } + } + ], + "swap_config": { + "strategies": ["soroswap"], + "cron_schedule": "0 */6 * * *", + "min_balance_threshold": 50000000 + } + } + } + ] +} +``` + +Set the required environment variables: + + + For testnet, you must provide all environment variables. For mainnet, the code + already contains default addresses, but you can override them using + environment variables. + + +```bash +# Stellar FeeForwarder Contract Addresses +# STELLAR_MAINNET_FEE_FORWARDER_ADDRESS= +STELLAR_TESTNET_FEE_FORWARDER_ADDRESS= + +# Stellar Soroswap Router Contract Addresses +STELLAR_MAINNET_SOROSWAP_ROUTER_ADDRESS= +STELLAR_TESTNET_SOROSWAP_ROUTER_ADDRESS= + +# Stellar Soroswap Factory Contract Addresses +STELLAR_MAINNET_SOROSWAP_FACTORY_ADDRESS= +STELLAR_TESTNET_SOROSWAP_FACTORY_ADDRESS= + +# Stellar Soroswap Native Wrapper (XLM) Contract Addresses +STELLAR_MAINNET_SOROSWAP_NATIVE_WRAPPER_ADDRESS= +STELLAR_TESTNET_SOROSWAP_NATIVE_WRAPPER_ADDRESS= +``` + +### Using the API + +The Soroban gas abstraction flow uses the same endpoints as classic sponsored transactions but with key differences in the request/response payloads and an additional authorization signing step. + +#### Step 1: Get Fee Quote + +Estimate the fee for a Soroban contract invocation in your preferred token. + +**Endpoint:** `POST /api/v1/relayers/{relayer_id}/transactions/sponsored/quote` + +**Request Body:** + +The `transaction_xdr` should contain the Soroban `InvokeHostFunction` operation, and the `fee_token` should be a Soroban token contract address (`C...` format). + +```json +{ + "transaction_xdr": "AAAAAgAAAAD...", + "fee_token": "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" +} +``` + +**Response:** + +The response includes `max_fee_in_token` and `max_fee_in_token_ui` fields that account for slippage buffer. These represent the maximum amount the user authorizes. + +```json +{ + "success": true, + "data": { + "fee_in_token_ui": "1.5", + "fee_in_token": "15000000", + "conversion_rate": "0.15", + "max_fee_in_token": "15750000", + "max_fee_in_token_ui": "1.575" + } +} +``` + +#### Step 2: Build Sponsored Transaction + +Prepare a Soroban transaction that wraps your contract call with the FeeForwarder contract. + +**Endpoint:** `POST /api/v1/relayers/{relayer_id}/transactions/sponsored/build` + +**Request Body:** + +```json +{ + "transaction_xdr": "AAAAAgAAAAD...", + "fee_token": "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" +} +``` + +**Response:** + +The response includes a `user_auth_entry` field containing the Soroban authorization entry that the user must sign. This authorization entry authorizes the FeeForwarder contract to collect fees in the specified token on the user's behalf. + +```json +{ + "success": true, + "data": { + "transaction": "AAAAAgAAAAD...", + "fee_in_token_ui": "1.5", + "fee_in_token": "15000000", + "fee_in_stroops": "100000", + "fee_token": "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + "valid_until": "2024-01-01T00:01:00Z", + "user_auth_entry": "AAAABgAAAAA...", + "max_fee_in_token": "15750000", + "max_fee_in_token_ui": "1.575" + } +} +``` + +#### Step 3: Sign the Authorization Entry + +Unlike classic sponsored transactions where the user signs the full transaction XDR, for Soroban gas abstraction the user must sign the **`user_auth_entry`** returned from the Build response. This is a `SorobanAuthorizationEntry` XDR that authorizes: + +1. The FeeForwarder contract to transfer fee tokens from the user to the relayer +2. The user's original contract invocation (wrapped inside the FeeForwarder call) + +The signing method depends on your setup: + +- **Programmatic signing**: Use the Stellar SDK to sign the authorization entry with the user's private key +- **Wallet signing**: Use a Soroban-compatible wallet that supports signing authorization entries +- **CLI helper tool**: A ready-to-use signing tool is provided in the repository at `helpers/sign_soroban_auth_entry.rs` + +##### Using the CLI Helper Tool + +The repository includes a helper tool that signs Soroban authorization entries directly from the command line. This is useful for testing and development: + +```bash +cargo run --example sign_soroban_auth_entry -- \ + --secret-key "S..." \ + --auth-entry "" \ + --network testnet +``` + +To generate the full JSON payload ready for the `/transactions` endpoint, include the `--transaction-xdr` flag: + +```bash +cargo run --example sign_soroban_auth_entry -- \ + --secret-key "S..." \ + --auth-entry "" \ + --network testnet \ + --transaction-xdr "" +``` + +This outputs a JSON object with `network`, `transaction_xdr`, and `signed_auth_entry` fields that can be sent directly to the Submit endpoint. + +For complete code examples of signing authorization entries and testing the full gas abstraction flow (quote, build, sign, and send), see the [Relayer SDK examples](https://github.com/OpenZeppelin/openzeppelin-relayer-sdk/tree/main/examples/relayers/stellar/src/soroban). The example demonstrates the end-to-end flow including fee estimation, transaction building, authorization signing, and submission. + +#### Step 4: Submit to Relayer + +Submit the transaction XDR from the Build response along with the user's **signed authorization entry**. The relayer will: + +1. Inject the user's signed authorization entry into the transaction +2. Add the relayer's own authorization entry (as the transaction source account) +3. Re-simulate the transaction with the signed auth entries for accurate resource calculation +4. Apply a resource buffer (15% safety margin) to prevent execution failures +5. Sign the transaction with the relayer's key +6. Submit it to the Stellar network + +**Endpoint:** `POST /api/v1/relayers/{relayer_id}/transactions` + +**Request Body:** + +```json +{ + "transaction_xdr": "AAAAAgAAAAD...", + "network": "testnet", + "signed_auth_entry": "AAAABgAAAAA..." +} +``` + +> **Note:** The `signed_auth_entry` field is used instead of `fee_bump` for Soroban gas abstraction. These two fields are mutually exclusive. + +**Response:** + +```json +{ + "success": true, + "data": { + "id": "tx-123456", + "status": "pending", + "network_data": { + "signature": "abc123...", + "transaction": "AAAAAgAAAAD..." + } + } +} +``` + +### Fee Token Format + +For Soroban gas abstraction, fee tokens are specified using Soroban contract addresses: + +- **Soroban token**: `"C..."` (e.g., `"CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"`) + +This differs from classic sponsored transactions which use the `"CODE:ISSUER"` format. The relayer automatically detects whether to use the classic or Soroban flow based on the `fee_token` format and the presence of `InvokeHostFunction` operations in the transaction XDR. + +### How the FeeForwarder Contract Works + +The FeeForwarder contract acts as a proxy that: + +1. **Collects the fee**: Transfers the fee token amount from the user to the relayer +2. **Executes the user's call**: Invokes the target contract function on behalf of the user +3. **Manages authorization**: Bundles fee approval and contract invocation into a single authorization entry + +The contract's `forward()` function takes these parameters: + +- `fee_token` - Soroban token contract address for fee payment +- `fee_amount` - Actual fee to charge +- `max_fee_amount` - Maximum fee authorized by the user (includes slippage buffer) +- `expiration_ledger` - Ledger number when the authorization expires +- `target_contract` - The contract the user wants to invoke +- `target_fn` - The function name to call +- `target_args` - The function arguments +- `user` - The user's account address +- `relayer` - The relayer's account address (fee recipient) + +### Best Practices for Soroban Gas Abstraction + +1. **Use the Quote endpoint first**: Always get a fee estimate before building to show users the expected cost, including the max fee with slippage +2. **Handle authorization expiration**: Authorization entries expire at a specific ledger. Rebuild if the authorization has expired +3. **Validate token contract addresses**: Ensure fee tokens use valid Soroban contract addresses (`C...` format, 56 characters) +4. **Monitor resource fees**: Soroban transactions have dynamic resource fees based on CPU instructions, storage reads/writes, and ledger entry access. The relayer applies a 15% buffer, but complex contract calls may need higher limits +5. **Set appropriate max fees**: Configure `max_allowed_fee` per token to prevent excessive fees during network congestion +6. **Configure the FeeForwarder address**: Ensure the correct FeeForwarder contract address is set via environment variables for each network + ## Additional Resources - [Stellar Documentation](https://developers.stellar.org/) diff --git a/helpers/sign_soroban_auth_entry.rs b/helpers/sign_soroban_auth_entry.rs new file mode 100644 index 000000000..45b96c7d8 --- /dev/null +++ b/helpers/sign_soroban_auth_entry.rs @@ -0,0 +1,291 @@ +//! Soroban Authorization Entry Signing Tool +//! +//! Signs a `user_auth_entry` from the `/build` endpoint response for gas abstraction. +//! +//! # Usage +//! +//! ```bash +//! cargo run --example sign_soroban_auth_entry -- \ +//! --secret-key "S..." \ +//! --auth-entry "" \ +//! --network testnet +//! ``` +//! +//! # Output +//! +//! - Default: Signed auth entry as base64 XDR +//! - With `--transaction-xdr`: JSON payload for `/transactions` endpoint + +use clap::Parser; +use ed25519_dalek::{Signer, SigningKey}; +use eyre::{eyre, Result, WrapErr}; +use sha2::{Digest, Sha256}; +use soroban_rs::xdr::{ + AccountId, Hash, HashIdPreimage, HashIdPreimageSorobanAuthorization, Limits, ReadXdr, + ScAddress, ScBytes, ScMap, ScMapEntry, ScSymbol, ScVal, ScVec, SorobanAddressCredentials, + SorobanAuthorizationEntry, SorobanCredentials, WriteXdr, +}; +use stellar_strkey::ed25519::{PrivateKey, PublicKey}; + +/// CLI arguments for signing Soroban authorization entries +#[derive(Parser, Debug)] +#[command(name = "sign-auth-entry")] +#[command(about = "Sign Soroban authorization entries for gas abstraction")] +#[command(version)] +struct Args { + /// Stellar secret key (S... format) + #[arg(short, long)] + secret_key: String, + + /// Base64 XDR encoded SorobanAuthorizationEntry (from /build endpoint) + #[arg(short, long)] + auth_entry: String, + + /// Network: "testnet", "mainnet", or "custom:" + #[arg(short, long, default_value = "testnet")] + network: String, + + /// Transaction XDR from /build endpoint (optional, for JSON output) + #[arg(short, long)] + transaction_xdr: Option, +} + +/// Resolves network name to passphrase +fn resolve_network_passphrase(network: &str) -> Result { + let lower = network.to_lowercase(); + if lower == "testnet" { + Ok("Test SDF Network ; September 2015".to_string()) + } else if lower == "mainnet" { + Ok("Public Global Stellar Network ; September 2015".to_string()) + } else if lower.starts_with("custom:") { + // Preserve original case for custom passphrase + Ok(network + .get(7..) // Skip "custom:" prefix + .ok_or_else(|| eyre!("Invalid custom network format"))? + .to_string()) + } else { + Err(eyre!( + "Invalid network '{}'. Use 'testnet', 'mainnet', or 'custom:'", + network + )) + } +} + +/// Extract the public key bytes from an ScAddress (Account type) +fn extract_account_public_key(address: &ScAddress) -> Result<[u8; 32]> { + match address { + ScAddress::Account(AccountId(public_key)) => match public_key { + soroban_rs::xdr::PublicKey::PublicKeyTypeEd25519(uint256) => Ok(uint256.0), + }, + _ => Err(eyre!( + "Expected Account address (G...), got a different address type" + )), + } +} + +/// Signs a Soroban authorization entry +fn sign_auth_entry( + secret_key: &str, + auth_entry_xdr: &str, + network_passphrase: &str, +) -> Result { + // 1. Parse the secret key + let private_key = + PrivateKey::from_string(secret_key).map_err(|e| eyre!("Invalid secret key: {}", e))?; + let signing_key = SigningKey::from_bytes(&private_key.0); + let verifying_key = signing_key.verifying_key(); + let public_key_bytes = verifying_key.to_bytes(); + + // 2. Decode the auth entry + let auth_entry = SorobanAuthorizationEntry::from_xdr_base64(auth_entry_xdr, Limits::none()) + .wrap_err("Failed to decode auth entry XDR")?; + + // 3. Extract credentials + let addr_creds = match &auth_entry.credentials { + SorobanCredentials::Address(creds) => creds, + _ => return Err(eyre!("Expected Address credentials in auth entry")), + }; + + // 4. Verify the public key matches the address in the auth entry + let expected_public_key = extract_account_public_key(&addr_creds.address)?; + let signer_public_key = PublicKey(public_key_bytes); + let expected_g_address = PublicKey(expected_public_key); + + eprintln!( + "Auth entry address: G{}", + stellar_strkey::Strkey::PublicKeyEd25519(expected_g_address) + .to_string() + .strip_prefix("G") + .unwrap_or("") + ); + eprintln!( + "Signer public key: G{}", + stellar_strkey::Strkey::PublicKeyEd25519(signer_public_key) + .to_string() + .strip_prefix("G") + .unwrap_or("") + ); + + if public_key_bytes != expected_public_key { + return Err(eyre!( + "Secret key does not match auth entry address!\n\ + Expected: {}\n\ + Got: {}", + stellar_strkey::Strkey::PublicKeyEd25519(expected_g_address), + stellar_strkey::Strkey::PublicKeyEd25519(signer_public_key) + )); + } + + eprintln!("Public key matches auth entry address"); + + // 5. Create the network ID (hash of passphrase) + let network_id_bytes: [u8; 32] = Sha256::digest(network_passphrase.as_bytes()).into(); + let network_id = Hash(network_id_bytes); + + eprintln!("Nonce: {}", addr_creds.nonce); + eprintln!( + "Signature expiration ledger: {}", + addr_creds.signature_expiration_ledger + ); + + // 6. Build the preimage for signing + let preimage = HashIdPreimage::SorobanAuthorization(HashIdPreimageSorobanAuthorization { + network_id, + nonce: addr_creds.nonce, + signature_expiration_ledger: addr_creds.signature_expiration_ledger, + invocation: auth_entry.root_invocation.clone(), + }); + + // 7. Serialize preimage to XDR and hash + let preimage_xdr = preimage + .to_xdr(Limits::none()) + .wrap_err("Failed to serialize preimage")?; + let preimage_hash = Sha256::digest(&preimage_xdr); + + eprintln!("Preimage hash: {}", hex::encode(&preimage_hash)); + + // 8. Sign the hash + let signature = signing_key.sign(&preimage_hash); + + eprintln!( + "Signature (64 bytes): {}", + hex::encode(signature.to_bytes()) + ); + + // 10. Create the signature ScVal (Vec containing a Map with public_key and signature) + // Format: Vec where each entry is a Map with: + // - "public_key": BytesN<32> + // - "signature": BytesN<64> + let signature_map = ScMap::try_from(vec![ + ScMapEntry { + key: ScVal::Symbol( + ScSymbol::try_from("public_key".as_bytes().to_vec()) + .map_err(|_| eyre!("Failed to create public_key symbol"))?, + ), + val: ScVal::Bytes( + ScBytes::try_from(public_key_bytes.to_vec()) + .map_err(|_| eyre!("Failed to create public_key bytes"))?, + ), + }, + ScMapEntry { + key: ScVal::Symbol( + ScSymbol::try_from("signature".as_bytes().to_vec()) + .map_err(|_| eyre!("Failed to create signature symbol"))?, + ), + val: ScVal::Bytes( + ScBytes::try_from(signature.to_bytes().to_vec()) + .map_err(|_| eyre!("Failed to create signature bytes"))?, + ), + }, + ]) + .map_err(|_| eyre!("Failed to create signature map"))?; + + let signature_scval = ScVal::Vec(Some( + ScVec::try_from(vec![ScVal::Map(Some(signature_map))]) + .map_err(|_| eyre!("Failed to create signature vec"))?, + )); + + eprintln!("Created signature ScVal structure"); + + // 11. Create the signed auth entry + let signed_auth_entry = SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: addr_creds.address.clone(), + nonce: addr_creds.nonce, + signature_expiration_ledger: addr_creds.signature_expiration_ledger, + signature: signature_scval, + }), + root_invocation: auth_entry.root_invocation, + }; + + // 12. Serialize to base64 XDR + let signed_xdr = signed_auth_entry + .to_xdr_base64(Limits::none()) + .wrap_err("Failed to serialize signed auth entry")?; + + Ok(signed_xdr) +} + +fn main() -> Result<()> { + let args = Args::parse(); + + // Resolve network passphrase + let network_passphrase = resolve_network_passphrase(&args.network)?; + + // Sign the auth entry + let signed_auth_entry = + sign_auth_entry(&args.secret_key, &args.auth_entry, &network_passphrase)?; + + // Output based on whether transaction_xdr was provided + match args.transaction_xdr { + Some(tx_xdr) => { + // Output JSON payload for /transactions endpoint + let payload = serde_json::json!({ + "network": args.network, + "transaction_xdr": tx_xdr, + "signed_auth_entry": signed_auth_entry + }); + println!("{}", serde_json::to_string_pretty(&payload)?); + } + None => { + eprintln!("Auth entry signed successfully"); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolve_network_passphrase_testnet() { + let passphrase = resolve_network_passphrase("testnet").unwrap(); + assert_eq!(passphrase, "Test SDF Network ; September 2015"); + } + + #[test] + fn test_resolve_network_passphrase_mainnet() { + let passphrase = resolve_network_passphrase("mainnet").unwrap(); + assert_eq!(passphrase, "Public Global Stellar Network ; September 2015"); + } + + #[test] + fn test_resolve_network_passphrase_custom() { + let passphrase = resolve_network_passphrase("custom:My Custom Network").unwrap(); + assert_eq!(passphrase, "My Custom Network"); + } + + #[test] + fn test_resolve_network_passphrase_invalid() { + let result = resolve_network_passphrase("invalid"); + assert!(result.is_err()); + } + + #[test] + fn test_resolve_network_passphrase_case_insensitive() { + let passphrase = resolve_network_passphrase("TESTNET").unwrap(); + assert_eq!(passphrase, "Test SDF Network ; September 2015"); + } +} diff --git a/openapi.json b/openapi.json index 18c9327f1..5c7673329 100644 --- a/openapi.json +++ b/openapi.json @@ -5403,7 +5403,7 @@ }, { "$ref": "#/components/schemas/StellarPrepareTransactionResult", - "description": "Stellar-specific prepare transaction result" + "description": "Stellar-specific prepare transaction result (classic and Soroban)\nFor Soroban: includes optional user_auth_entry, expiration_ledger" } ], "description": "Network-agnostic prepare transaction response for gasless transactions.\nContains network-specific prepare transaction results." @@ -5436,7 +5436,7 @@ }, { "$ref": "#/components/schemas/StellarFeeEstimateResult", - "description": "Stellar-specific fee estimate result" + "description": "Stellar-specific fee estimate result (classic and Soroban)" } ], "description": "Network-agnostic fee estimate response for gasless transactions.\nContains network-specific fee estimate results." @@ -9410,10 +9410,10 @@ }, { "$ref": "#/components/schemas/StellarPrepareTransactionRequestParams", - "description": "Stellar-specific prepare transaction request parameters" + "description": "Stellar-specific prepare transaction request parameters (classic and Soroban)" } ], - "description": "Network-agnostic prepare transaction request parameters for gasless transactions.\nContains network-specific request parameters for preparing transactions with fee payments.\nThe network type is inferred from the relayer's network configuration." + "description": "Network-agnostic prepare transaction request parameters for gasless transactions.\nContains network-specific request parameters for preparing transactions with fee payments.\nThe network type is inferred from the relayer's network configuration.\n\nFor Stellar, supports both classic and Soroban gas abstraction:\n- Classic: Pass operations or transaction_xdr with classic fee token\n- Soroban: Pass transaction_xdr containing InvokeHostFunction, user_address, and contract fee token" }, "SponsoredTransactionBuildResponse": { "oneOf": [ @@ -9423,7 +9423,7 @@ }, { "$ref": "#/components/schemas/StellarPrepareTransactionResult", - "description": "Stellar-specific prepare transaction result" + "description": "Stellar-specific prepare transaction result (classic and Soroban)\nFor Soroban: includes optional user_auth_entry, expiration_ledger" } ], "description": "Network-agnostic prepare transaction response for gasless transactions.\nContains network-specific prepare transaction results." @@ -9436,10 +9436,10 @@ }, { "$ref": "#/components/schemas/StellarFeeEstimateRequestParams", - "description": "Stellar-specific fee estimate request parameters" + "description": "Stellar-specific fee estimate request parameters (classic and Soroban)" } ], - "description": "Network-agnostic fee estimate request parameters for gasless transactions.\nContains network-specific request parameters for fee estimation.\nThe network type is inferred from the relayer's network configuration." + "description": "Network-agnostic fee estimate request parameters for gasless transactions.\nContains network-specific request parameters for fee estimation.\nThe network type is inferred from the relayer's network configuration.\n\nFor Stellar, supports both classic and Soroban gas abstraction:\n- Classic: Pass operations or transaction_xdr with classic fee token (native/USDC:GA...)\n- Soroban: Pass transaction_xdr containing InvokeHostFunction, user_address, and contract fee token (C...)" }, "SponsoredTransactionQuoteResponse": { "oneOf": [ @@ -9449,7 +9449,7 @@ }, { "$ref": "#/components/schemas/StellarFeeEstimateResult", - "description": "Stellar-specific fee estimate result" + "description": "Stellar-specific fee estimate result (classic and Soroban)" } ], "description": "Network-agnostic fee estimate response for gasless transactions.\nContains network-specific fee estimate results." @@ -9516,7 +9516,7 @@ "properties": { "fee_token": { "type": "string", - "description": "Asset identifier for fee token (e.g., \"native\" or \"USDC:GA5Z...\")" + "description": "Asset identifier for fee token.\nFor classic: \"native\" or \"USDC:GA5Z...\" format.\nFor Soroban: contract address (C...) format." }, "operations": { "type": [ @@ -9540,7 +9540,7 @@ "string", "null" ], - "description": "Pre-built transaction XDR (base64 encoded, signed or unsigned)\nMutually exclusive with operations field" + "description": "Pre-built transaction XDR (base64 encoded, signed or unsigned)\nMutually exclusive with operations field.\nFor Soroban gas abstraction: pass XDR containing InvokeHostFunction operation." } }, "additionalProperties": false @@ -9564,6 +9564,20 @@ "fee_in_token_ui": { "type": "string", "description": "Estimated fee in token amount (decimal UI representation as string)" + }, + "max_fee_in_token": { + "type": [ + "string", + "null" + ], + "description": "Maximum fee in token amount (raw units as string).\nOnly present for Soroban gas abstraction - includes slippage buffer." + }, + "max_fee_in_token_ui": { + "type": [ + "string", + "null" + ], + "description": "Maximum fee in token amount (decimal UI representation as string).\nOnly present for Soroban gas abstraction - includes slippage buffer." } } }, @@ -9628,7 +9642,7 @@ "properties": { "fee_token": { "type": "string", - "description": "Asset identifier for fee token" + "description": "Asset identifier for fee token.\nFor classic: \"native\" or \"USDC:GA5Z...\" format.\nFor Soroban: contract address (C...) format." }, "operations": { "type": [ @@ -9652,7 +9666,7 @@ "string", "null" ], - "description": "Pre-built transaction XDR (base64 encoded, signed or unsigned)\nMutually exclusive with operations field" + "description": "Pre-built transaction XDR (base64 encoded, signed or unsigned)\nMutually exclusive with operations field.\nFor Soroban gas abstraction: pass XDR containing InvokeHostFunction operation." } }, "additionalProperties": false @@ -9684,10 +9698,31 @@ "type": "string", "description": "Asset identifier for fee token" }, + "max_fee_in_token": { + "type": [ + "string", + "null" + ], + "description": "Maximum fee in token amount (raw units as string).\nOnly present for Soroban gas abstraction - includes slippage buffer." + }, + "max_fee_in_token_ui": { + "type": [ + "string", + "null" + ], + "description": "Maximum fee in token amount (decimal UI representation as string).\nOnly present for Soroban gas abstraction - includes slippage buffer." + }, "transaction": { "type": "string", "description": "Extended transaction XDR (base64 encoded)" }, + "user_auth_entry": { + "type": [ + "string", + "null" + ], + "description": "User authorization entry XDR (base64 encoded).\nPresent for Soroban gas abstraction - user must sign this auth entry." + }, "valid_until": { "type": "string", "description": "Transaction validity timestamp (ISO 8601 format)" @@ -9844,6 +9879,13 @@ "$ref": "#/components/schemas/OperationSpec" } }, + "signed_auth_entry": { + "type": [ + "string", + "null" + ], + "description": "Signed Soroban authorization entry (base64 encoded SorobanAuthorizationEntry XDR)\nUsed for Soroban gas abstraction: contains the user's signed auth entry from /build response.\nWhen provided, transaction_xdr must also be provided (the FeeForwarder transaction from /build).\nThe relayer will inject this signed auth entry into the transaction before submitting." + }, "source_account": { "type": [ "string", @@ -9855,7 +9897,7 @@ "string", "null" ], - "description": "Pre-built transaction XDR (base64 encoded, signed or unsigned)\nMutually exclusive with operations field" + "description": "Pre-built transaction XDR (base64 encoded, signed or unsigned)\nMutually exclusive with operations field.\nFor Soroban gas abstraction: submit the transaction XDR from sponsored/build response\nwith the user's signed auth entry updated inside." }, "valid_until": { "type": [ diff --git a/src/config/server_config.rs b/src/config/server_config.rs index 0e37559aa..d1ba03bf2 100644 --- a/src/config/server_config.rs +++ b/src/config/server_config.rs @@ -6,6 +6,8 @@ use crate::{ constants::{ DEFAULT_PROVIDER_FAILURE_EXPIRATION_SECS, DEFAULT_PROVIDER_FAILURE_THRESHOLD, DEFAULT_PROVIDER_PAUSE_DURATION_SECS, MINIMUM_SECRET_VALUE_LENGTH, + STELLAR_FEE_FORWARDER_MAINNET, STELLAR_SOROSWAP_MAINNET_FACTORY, + STELLAR_SOROSWAP_MAINNET_NATIVE_WRAPPER, STELLAR_SOROSWAP_MAINNET_ROUTER, }, models::SecretString, }; @@ -28,6 +30,15 @@ impl FromStr for RepositoryStorageType { } } +/// Returns `Some(s.to_string())` when `s` is non-empty, `None` otherwise. +fn non_empty_const(s: &str) -> Option { + if s.is_empty() { + None + } else { + Some(s.to_string()) + } +} + #[derive(Debug, Clone)] pub struct ServerConfig { /// The host address the server will bind to. @@ -101,6 +112,22 @@ pub struct ServerConfig { pub connection_backlog: u32, /// Request handler timeout in seconds for API endpoints. pub request_timeout_seconds: u64, + /// Stellar mainnet FeeForwarder contract address for gas abstraction. + pub stellar_mainnet_fee_forwarder_address: Option, + /// Stellar testnet FeeForwarder contract address for gas abstraction. + pub stellar_testnet_fee_forwarder_address: Option, + /// Stellar mainnet Soroswap router contract address. + pub stellar_mainnet_soroswap_router_address: Option, + /// Stellar testnet Soroswap router contract address. + pub stellar_testnet_soroswap_router_address: Option, + /// Stellar mainnet Soroswap factory contract address. + pub stellar_mainnet_soroswap_factory_address: Option, + /// Stellar testnet Soroswap factory contract address. + pub stellar_testnet_soroswap_factory_address: Option, + /// Stellar mainnet native XLM wrapper token address for Soroswap. + pub stellar_mainnet_soroswap_native_wrapper_address: Option, + /// Stellar testnet native XLM wrapper token address for Soroswap. + pub stellar_testnet_soroswap_native_wrapper_address: Option, } impl ServerConfig { @@ -165,6 +192,22 @@ impl ServerConfig { max_connections: Self::get_max_connections(), connection_backlog: Self::get_connection_backlog(), request_timeout_seconds: Self::get_request_timeout_seconds(), + stellar_mainnet_fee_forwarder_address: Self::get_stellar_mainnet_fee_forwarder_address( + ), + stellar_testnet_fee_forwarder_address: Self::get_stellar_testnet_fee_forwarder_address( + ), + stellar_mainnet_soroswap_router_address: + Self::get_stellar_mainnet_soroswap_router_address(), + stellar_testnet_soroswap_router_address: + Self::get_stellar_testnet_soroswap_router_address(), + stellar_mainnet_soroswap_factory_address: + Self::get_stellar_mainnet_soroswap_factory_address(), + stellar_testnet_soroswap_factory_address: + Self::get_stellar_testnet_soroswap_factory_address(), + stellar_mainnet_soroswap_native_wrapper_address: + Self::get_stellar_mainnet_soroswap_native_wrapper_address(), + stellar_testnet_soroswap_native_wrapper_address: + Self::get_stellar_testnet_soroswap_native_wrapper_address(), } } @@ -478,6 +521,88 @@ impl ServerConfig { .unwrap_or(30) } + // ========================================================================= + // Stellar Contract Address Getters (raw env var reads) + // ========================================================================= + + pub fn get_stellar_mainnet_fee_forwarder_address() -> Option { + env::var("STELLAR_MAINNET_FEE_FORWARDER_ADDRESS").ok() + } + + pub fn get_stellar_testnet_fee_forwarder_address() -> Option { + env::var("STELLAR_TESTNET_FEE_FORWARDER_ADDRESS").ok() + } + + pub fn get_stellar_mainnet_soroswap_router_address() -> Option { + env::var("STELLAR_MAINNET_SOROSWAP_ROUTER_ADDRESS").ok() + } + + pub fn get_stellar_testnet_soroswap_router_address() -> Option { + env::var("STELLAR_TESTNET_SOROSWAP_ROUTER_ADDRESS").ok() + } + + pub fn get_stellar_mainnet_soroswap_factory_address() -> Option { + env::var("STELLAR_MAINNET_SOROSWAP_FACTORY_ADDRESS").ok() + } + + pub fn get_stellar_testnet_soroswap_factory_address() -> Option { + env::var("STELLAR_TESTNET_SOROSWAP_FACTORY_ADDRESS").ok() + } + + pub fn get_stellar_mainnet_soroswap_native_wrapper_address() -> Option { + env::var("STELLAR_MAINNET_SOROSWAP_NATIVE_WRAPPER_ADDRESS").ok() + } + + pub fn get_stellar_testnet_soroswap_native_wrapper_address() -> Option { + env::var("STELLAR_TESTNET_SOROSWAP_NATIVE_WRAPPER_ADDRESS").ok() + } + + // ========================================================================= + // Stellar Contract Address Resolvers + // ========================================================================= + // For mainnet: env var override → hardcoded default from constants. + // For testnet: env var only (no hardcoded defaults). + + /// Resolves the FeeForwarder contract address for the given network. + pub fn resolve_stellar_fee_forwarder_address(is_testnet: bool) -> Option { + if is_testnet { + Self::get_stellar_testnet_fee_forwarder_address() + } else { + Self::get_stellar_mainnet_fee_forwarder_address() + .or_else(|| non_empty_const(STELLAR_FEE_FORWARDER_MAINNET)) + } + } + + /// Resolves the Soroswap router contract address for the given network. + pub fn resolve_stellar_soroswap_router_address(is_testnet: bool) -> Option { + if is_testnet { + Self::get_stellar_testnet_soroswap_router_address() + } else { + Self::get_stellar_mainnet_soroswap_router_address() + .or_else(|| Some(STELLAR_SOROSWAP_MAINNET_ROUTER.to_string())) + } + } + + /// Resolves the Soroswap factory contract address for the given network. + pub fn resolve_stellar_soroswap_factory_address(is_testnet: bool) -> Option { + if is_testnet { + Self::get_stellar_testnet_soroswap_factory_address() + } else { + Self::get_stellar_mainnet_soroswap_factory_address() + .or_else(|| Some(STELLAR_SOROSWAP_MAINNET_FACTORY.to_string())) + } + } + + /// Resolves the Soroswap native wrapper token address for the given network. + pub fn resolve_stellar_soroswap_native_wrapper_address(is_testnet: bool) -> Option { + if is_testnet { + Self::get_stellar_testnet_soroswap_native_wrapper_address() + } else { + Self::get_stellar_mainnet_soroswap_native_wrapper_address() + .or_else(|| Some(STELLAR_SOROSWAP_MAINNET_NATIVE_WRAPPER.to_string())) + } + } + /// Get worker concurrency from environment variable or use default /// /// Environment variable format: `BACKGROUND_WORKER_{WORKER_NAME}_CONCURRENCY` diff --git a/src/constants/stellar_transaction.rs b/src/constants/stellar_transaction.rs index 8d757703f..4d098ba41 100644 --- a/src/constants/stellar_transaction.rs +++ b/src/constants/stellar_transaction.rs @@ -2,9 +2,22 @@ //! //! This module contains default values used throughout the Stellar transaction //! handling logic, including fees, retry delays, and timeout thresholds. +//! +//! ## Gas Abstraction Contract Addresses +//! +//! This module also contains default contract addresses for Soroban gas abstraction: +//! - **Soroswap**: AMM DEX for token-to-XLM quotes and swaps +//! - **FeeForwarder**: Contract that enables fee payment in tokens instead of XLM +//! +//! These addresses can be overridden via environment variables if needed. +//! See `ServerConfig` for the corresponding env var names. use chrono::Duration; +// ============================================================================= +// Transaction Fees +// ============================================================================= + pub const STELLAR_DEFAULT_TRANSACTION_FEE: u32 = 100; /// Default maximum fee for fee-bump transactions (0.1 XLM = 1,000,000 stroops) pub const STELLAR_DEFAULT_MAX_FEE: i64 = 1_000_000; @@ -27,10 +40,17 @@ pub const STELLAR_STATUS_CHECK_INITIAL_DELAY_SECONDS: i64 = 2; pub const STELLAR_PENDING_RECOVERY_TRIGGER_SECONDS: i64 = 10; // Transaction validity +/// Approximate Stellar ledger close time in seconds (used for ledger-based expiration) +pub const STELLAR_LEDGER_TIME_SECONDS: u64 = 5; + /// Default transaction validity duration (in minutes) for sponsored transactions /// Provides reasonable time for users to review and submit while ensuring transaction doesn't expire too quickly pub const STELLAR_SPONSORED_TRANSACTION_VALIDITY_MINUTES: i64 = 2; +/// Sponsored transaction validity in seconds (2 minutes). +/// Used for gas abstraction authorization validity so it aligns with the transaction submission window. +pub const STELLAR_SPONSORED_TRANSACTION_VALIDITY_SECONDS: u64 = 120; + /// Get status check initial delay duration pub fn get_stellar_status_check_initial_delay() -> Duration { Duration::seconds(STELLAR_STATUS_CHECK_INITIAL_DELAY_SECONDS) @@ -59,3 +79,40 @@ pub fn get_stellar_resend_timeout() -> Duration { pub fn get_stellar_max_stuck_transaction_lifetime() -> Duration { Duration::minutes(STELLAR_MAX_STUCK_TRANSACTION_LIFETIME_MINUTES) } + +// ============================================================================= +// Soroswap DEX Contract Addresses (Mainnet Only) +// ============================================================================= +// Official Soroswap mainnet deployments from: +// https://github.com/soroswap/core/blob/main/public/mainnet.contracts.json +// +// Testnet addresses must be provided via environment variables: +// - STELLAR_TESTNET_SOROSWAP_ROUTER_ADDRESS +// - STELLAR_TESTNET_SOROSWAP_FACTORY_ADDRESS +// - STELLAR_TESTNET_SOROSWAP_NATIVE_WRAPPER_ADDRESS + +/// Soroswap router contract address on Stellar mainnet +pub const STELLAR_SOROSWAP_MAINNET_ROUTER: &str = + "CAG5LRYQ5JVEUI5TEID72EYOVX44TTUJT5BQR2J6J77FH65PCCFAJDDH"; + +/// Soroswap factory contract address on Stellar mainnet +pub const STELLAR_SOROSWAP_MAINNET_FACTORY: &str = + "CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2"; + +/// Native XLM wrapper token contract address on Stellar mainnet +/// This is the Soroban token contract that wraps native XLM for use in Soroswap +pub const STELLAR_SOROSWAP_MAINNET_NATIVE_WRAPPER: &str = + "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; + +// ============================================================================= +// FeeForwarder Contract Addresses (Mainnet Only) +// ============================================================================= +// The FeeForwarder contract enables gas abstraction by allowing users to pay +// transaction fees in tokens instead of native XLM. +// +// Testnet address must be provided via environment variable: +// - STELLAR_TESTNET_FEE_FORWARDER_ADDRESS + +/// FeeForwarder contract address on Stellar mainnet +/// Set to empty string until mainnet deployment is available +pub const STELLAR_FEE_FORWARDER_MAINNET: &str = ""; diff --git a/src/domain/relayer/stellar/gas_abstraction.rs b/src/domain/relayer/stellar/gas_abstraction.rs index af556b8c5..e6d90c108 100644 --- a/src/domain/relayer/stellar/gas_abstraction.rs +++ b/src/domain/relayer/stellar/gas_abstraction.rs @@ -10,14 +10,18 @@ use tracing::{debug, warn}; use crate::constants::{ get_stellar_sponsored_transaction_validity_duration, STELLAR_DEFAULT_TRANSACTION_FEE, + STELLAR_LEDGER_TIME_SECONDS, }; + use crate::domain::relayer::{ - stellar::xdr_utils::parse_transaction_xdr, GasAbstractionTrait, RelayerError, StellarRelayer, + stellar::utils::{apply_max_fee_slippage, get_expiration_ledger}, + stellar::xdr_utils::{extract_source_account, parse_transaction_xdr}, + GasAbstractionTrait, RelayerError, StellarRelayer, }; use crate::domain::transaction::stellar::{ utils::{ - add_operation_to_envelope, convert_xlm_fee_to_token, create_fee_payment_operation, - estimate_fee, set_time_bounds, FeeQuote, + add_operation_to_envelope, amount_to_ui_amount, convert_xlm_fee_to_token, + create_fee_payment_operation, estimate_fee, set_time_bounds, FeeQuote, }, StellarTransactionValidator, }; @@ -36,7 +40,103 @@ use crate::repositories::{ use crate::services::provider::StellarProviderTrait; use crate::services::signer::StellarSignTrait; use crate::services::stellar_dex::StellarDexServiceTrait; +use crate::services::stellar_fee_forwarder::{FeeForwarderParams, FeeForwarderService}; use crate::services::TransactionCounterServiceTrait; +use soroban_rs::xdr::{HostFunction, OperationBody, ReadXdr, ScVal}; + +/// Information extracted from a Soroban InvokeHostFunction operation +#[derive(Debug, Clone)] +pub struct SorobanInvokeInfo { + /// Target contract address (C... format) + pub target_contract: String, + /// Target function name + pub target_fn: String, + /// Target function arguments + pub target_args: Vec, +} + +/// Detect if a transaction XDR contains a Soroban InvokeHostFunction operation +/// and extract the contract call details. +/// +/// Returns: +/// - `Ok(Some(info))` if XDR contains an InvokeHostFunction operation +/// - `Ok(None)` if XDR is a classic transaction (no InvokeHostFunction) +/// - `Err(...)` if XDR is invalid +fn detect_soroban_invoke_from_xdr(xdr: &str) -> Result, RelayerError> { + use soroban_rs::xdr::TransactionEnvelope; + + let envelope = TransactionEnvelope::from_xdr_base64(xdr, Limits::none()) + .map_err(|e| RelayerError::ValidationError(format!("Invalid XDR: {e}")))?; + + // Extract operations from envelope + let operations = match &envelope { + TransactionEnvelope::TxV0(env) => env.tx.operations.to_vec(), + TransactionEnvelope::Tx(env) => env.tx.operations.to_vec(), + TransactionEnvelope::TxFeeBump(env) => match &env.tx.inner_tx { + soroban_rs::xdr::FeeBumpTransactionInnerTx::Tx(inner) => inner.tx.operations.to_vec(), + }, + }; + + let mut invoke_index = None; + let mut invoke_op = None; + + for (idx, op) in operations.iter().enumerate() { + if let OperationBody::InvokeHostFunction(invoke) = &op.body { + invoke_index = Some(idx); + invoke_op = Some(invoke); + break; + } + } + + if let Some(idx) = invoke_index { + // Soroban transactions must contain exactly one operation + if operations.len() != 1 { + return Err(RelayerError::ValidationError( + "Soroban transactions must contain exactly one operation".to_string(), + )); + } + + // Single-operation Soroban must be InvokeHostFunction + let invoke_op = invoke_op.ok_or_else(|| { + RelayerError::ValidationError("InvokeHostFunction operation missing".to_string()) + })?; + + if idx != 0 { + return Err(RelayerError::ValidationError( + "InvokeHostFunction must be the first operation".to_string(), + )); + } + + if let HostFunction::InvokeContract(invoke_args) = &invoke_op.host_function { + // Extract contract address + let target_contract = match &invoke_args.contract_address { + soroban_rs::xdr::ScAddress::Contract(contract_id) => { + stellar_strkey::Contract(contract_id.0 .0).to_string() + } + _ => { + return Err(RelayerError::ValidationError( + "InvokeHostFunction must target a contract address".to_string(), + )); + } + }; + + // Extract function name + let target_fn = invoke_args.function_name.to_utf8_string_lossy(); + + // Extract arguments + let target_args: Vec = invoke_args.args.to_vec(); + + return Ok(Some(SorobanInvokeInfo { + target_contract, + target_fn, + target_args, + })); + } + } + + // Not a Soroban InvokeHostFunction transaction + Ok(None) +} #[async_trait] impl GasAbstractionTrait @@ -63,13 +163,88 @@ where )); } }; + + // Check if this is a Soroban gas abstraction request by detecting InvokeHostFunction in XDR + // Soroban mode is detected when transaction_xdr contains an InvokeHostFunction operation + if let Some(xdr) = ¶ms.transaction_xdr { + if let Some(soroban_info) = detect_soroban_invoke_from_xdr(xdr)? { + return self.quote_soroban_from_xdr(¶ms, &soroban_info).await; + } + } + + // Classic sponsored transaction flow + self.quote_classic_sponsored(¶ms).await + } + + async fn build_sponsored_transaction( + &self, + params: SponsoredTransactionBuildRequest, + ) -> Result { + let params = match params { + SponsoredTransactionBuildRequest::Stellar(p) => p, + _ => { + return Err(RelayerError::ValidationError( + "Expected Stellar prepare transaction request parameters".to_string(), + )); + } + }; + + let policy = self.relayer.policies.get_stellar_policy(); + + // Validate allowed token + StellarTransactionValidator::validate_allowed_token(¶ms.fee_token, &policy) + .map_err(|e| RelayerError::ValidationError(e.to_string()))?; + + // Validate fee_payment_strategy is User + if !policy.is_user_fee_payment() { + return Err(RelayerError::ValidationError( + "Gas abstraction requires fee_payment_strategy: User".to_string(), + )); + } + + // Check if this is a Soroban gas abstraction request by detecting InvokeHostFunction in XDR + if let Some(xdr) = ¶ms.transaction_xdr { + if let Some(soroban_info) = detect_soroban_invoke_from_xdr(xdr)? { + return self.build_soroban_sponsored(¶ms, &soroban_info).await; + } + } + + // Classic sponsored transaction flow + self.build_classic_sponsored(¶ms).await + } +} + +// ============================================================================ +// Classic Sponsored Transaction Handlers (Fee-bump Flow) +// ============================================================================ + +impl StellarRelayer +where + P: StellarProviderTrait + Send + Sync, + D: StellarDexServiceTrait + Send + Sync + 'static, + RR: Repository + RelayerRepository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + TR: Repository + TransactionRepository + Send + Sync + 'static, + J: JobProducerTrait + Send + Sync + 'static, + TCS: TransactionCounterServiceTrait + Send + Sync + 'static, + S: StellarSignTrait + Send + Sync + 'static, +{ + /// Quote a classic sponsored transaction (fee-bump flow) + /// + /// Estimates the fee for a standard Stellar transaction where the relayer + /// pays the network fee and user pays in a token. + async fn quote_classic_sponsored( + &self, + params: &crate::models::StellarFeeEstimateRequestParams, + ) -> Result { debug!( - "Processing quote sponsored transaction request for token: {}", + "Processing classic quote sponsored transaction for token: {}", params.fee_token ); - // Validate allowed token let policy = self.relayer.policies.get_stellar_policy(); + + // Validate allowed token StellarTransactionValidator::validate_allowed_token(¶ms.fee_token, &policy) .map_err(|e| RelayerError::ValidationError(e.to_string()))?; @@ -80,7 +255,7 @@ where )); } - // Build envelope from XDR or operations (reusing logic from build method) + // Build envelope from XDR or operations let envelope = build_envelope_from_request( params.transaction_xdr.as_ref(), params.operations.as_ref(), @@ -90,7 +265,7 @@ where ) .await?; - // Run comprehensive security validation (similar to build method) + // Run comprehensive security validation StellarTransactionValidator::gasless_transaction_validation( &envelope, &self.relayer.address, @@ -109,13 +284,12 @@ where .map_err(crate::models::RelayerError::from)?; // Add fees for fee payment operation (100 stroops) and fee-bump transaction (100 stroops) - // For Soroban transactions, the simulation already accounts for resource fees, - // we just need to add the inclusion fees for the additional operations let is_soroban = xdr_needs_simulation(&envelope).unwrap_or(false); - let mut additional_fees = 0; - if !is_soroban { - additional_fees = 2 * STELLAR_DEFAULT_TRANSACTION_FEE as u64; // 200 stroops total - } + let additional_fees = if is_soroban { + 0 // Soroban simulation already accounts for resource fees + } else { + 2 * STELLAR_DEFAULT_TRANSACTION_FEE as u64 // 200 stroops total + }; let xlm_fee = inner_tx_fee + additional_fees; // Convert to token amount via DEX service @@ -150,35 +324,36 @@ where .await .map_err(|e| RelayerError::ValidationError(e.to_string()))?; - debug!("Fee estimate result: {:?}", fee_quote); + debug!("Classic fee estimate result: {:?}", fee_quote); - let result = StellarFeeEstimateResult { - fee_in_token_ui: fee_quote.fee_in_token_ui, - fee_in_token: fee_quote.fee_in_token.to_string(), - conversion_rate: fee_quote.conversion_rate.to_string(), - }; - Ok(SponsoredTransactionQuoteResponse::Stellar(result)) + Ok(SponsoredTransactionQuoteResponse::Stellar( + StellarFeeEstimateResult { + fee_in_token_ui: fee_quote.fee_in_token_ui, + fee_in_token: fee_quote.fee_in_token.to_string(), + conversion_rate: fee_quote.conversion_rate.to_string(), + // Classic transactions have deterministic fees, no slippage buffer needed + max_fee_in_token: None, + max_fee_in_token_ui: None, + }, + )) } - async fn build_sponsored_transaction( + /// Build a classic sponsored transaction (fee-bump flow) + /// + /// Builds a complete transaction envelope with fee payment operation, + /// ready for user signature. The relayer will later wrap this in a fee-bump. + async fn build_classic_sponsored( &self, - params: SponsoredTransactionBuildRequest, + params: &crate::models::StellarPrepareTransactionRequestParams, ) -> Result { - let params = match params { - SponsoredTransactionBuildRequest::Stellar(p) => p, - _ => { - return Err(RelayerError::ValidationError( - "Expected Stellar prepare transaction request parameters".to_string(), - )); - } - }; debug!( - "Processing prepare transaction request for token: {}", + "Processing classic build sponsored transaction for token: {}", params.fee_token ); - // Validate allowed token let policy = self.relayer.policies.get_stellar_policy(); + + // Validate allowed token StellarTransactionValidator::validate_allowed_token(¶ms.fee_token, &policy) .map_err(|e| RelayerError::ValidationError(e.to_string()))?; @@ -189,7 +364,7 @@ where )); } - // Build envelope from XDR or operations (reusing shared helper) + // Build envelope from XDR or operations let envelope = build_envelope_from_request( params.transaction_xdr.as_ref(), params.operations.as_ref(), @@ -199,6 +374,7 @@ where ) .await?; + // Run comprehensive security validation StellarTransactionValidator::gasless_transaction_validation( &envelope, &self.relayer.address, @@ -211,31 +387,29 @@ where RelayerError::ValidationError(format!("Failed to validate gasless transaction: {e}")) })?; - // Get fee estimate using estimate_fee utility which handles simulation if needed - // For non-Soroban transactions, we'll add 200 stroops (100 for fee payment op + 100 for fee-bump) + // Estimate fee using estimate_fee utility which handles simulation if needed let inner_tx_fee = estimate_fee(&envelope, &self.provider, None) .await .map_err(crate::models::RelayerError::from)?; - // Add fees for fee payment operation (100 stroops) and fee-bump transaction (100 stroops) - // For Soroban transactions, the simulation already accounts for resource fees, - // we just need to add the inclusion fees for the additional operations + // Add fees for fee payment operation and fee-bump transaction let is_soroban = xdr_needs_simulation(&envelope).unwrap_or(false); - let mut additional_fees = 0; - if !is_soroban { - additional_fees = 2 * STELLAR_DEFAULT_TRANSACTION_FEE as u64; // 200 stroops total - } + let additional_fees = if is_soroban { + 0 + } else { + 2 * STELLAR_DEFAULT_TRANSACTION_FEE as u64 // 200 stroops total + }; let xlm_fee = inner_tx_fee + additional_fees; debug!( inner_tx_fee = inner_tx_fee, additional_fees = additional_fees, total_fee = xlm_fee, - "Fee estimated: inner transaction + fee payment op + fee-bump transaction fee" + "Fee estimated: inner transaction + fee payment op + fee-bump" ); - // Calculate fee quote first to check user balance before modifying envelope - let preliminary_fee_quote = convert_xlm_fee_to_token( + // Calculate fee quote to check user balance before modifying envelope + let fee_quote = convert_xlm_fee_to_token( self.dex_service.as_ref(), &policy, xlm_fee, @@ -245,16 +419,13 @@ where .map_err(crate::models::RelayerError::from)?; // Validate max fee - StellarTransactionValidator::validate_max_fee( - preliminary_fee_quote.fee_in_stroops, - &policy, - ) - .map_err(|e| RelayerError::ValidationError(e.to_string()))?; + StellarTransactionValidator::validate_max_fee(fee_quote.fee_in_stroops, &policy) + .map_err(|e| RelayerError::ValidationError(e.to_string()))?; // Validate token-specific max fee StellarTransactionValidator::validate_token_max_fee( ¶ms.fee_token, - preliminary_fee_quote.fee_in_token, + fee_quote.fee_in_token, &policy, ) .map_err(|e| RelayerError::ValidationError(e.to_string()))?; @@ -263,7 +434,7 @@ where StellarTransactionValidator::validate_user_token_balance( &envelope, ¶ms.fee_token, - preliminary_fee_quote.fee_in_token, + fee_quote.fee_in_token, &self.provider, ) .await @@ -272,21 +443,18 @@ where // Add payment operation using the validated fee quote let mut final_envelope = add_payment_operation_to_envelope( envelope, - &preliminary_fee_quote, + &fee_quote, ¶ms.fee_token, &self.relayer.address, )?; - // Use the validated fee quote (no duplicate DEX call) - let fee_quote = preliminary_fee_quote; - debug!( estimated_fee = xlm_fee, final_fee_in_token = fee_quote.fee_in_token_ui, - "Transaction prepared successfully" + "Classic transaction prepared successfully" ); - // Set final time bounds just before returning to give user maximum time to review and submit + // Set final time bounds just before returning to give user maximum time to sign let valid_until = Utc::now() + get_stellar_sponsored_transaction_validity_duration(); set_time_bounds(&mut final_envelope, valid_until) .map_err(crate::models::RelayerError::from)?; @@ -302,190 +470,1830 @@ where fee_in_token: fee_quote.fee_in_token.to_string(), fee_in_token_ui: fee_quote.fee_in_token_ui, fee_in_stroops: fee_quote.fee_in_stroops.to_string(), - fee_token: params.fee_token, + fee_token: params.fee_token.clone(), valid_until: valid_until.to_rfc3339(), + // Classic mode: no Soroban-specific fields + user_auth_entry: None, + // Classic transactions have deterministic fees, no slippage buffer needed + max_fee_in_token: None, + max_fee_in_token_ui: None, }, )) } } -/// Add payment operation to envelope using a pre-computed fee quote -/// -/// This function adds a fee payment operation to the transaction envelope using -/// a pre-computed FeeQuote. This avoids duplicate DEX calls and ensures the -/// validated fee quote matches the fee amount in the payment operation. -/// -/// Note: Time bounds should be set separately just before returning the transaction -/// to give the user maximum time to review and submit. -/// -/// # Arguments -/// * `envelope` - The transaction envelope to add the payment operation to -/// * `fee_quote` - Pre-computed fee quote containing the token amount to charge -/// * `fee_token` - Asset identifier for the fee token -/// * `relayer_address` - Address of the relayer receiving the fee payment -/// -/// # Returns -/// The updated envelope with the payment operation added (if not Soroban) -fn add_payment_operation_to_envelope( - mut envelope: TransactionEnvelope, - fee_quote: &FeeQuote, - fee_token: &str, - relayer_address: &str, -) -> Result { - // Convert fee amount to i64 for payment operation - let fee_amount = i64::try_from(fee_quote.fee_in_token).map_err(|_| { - RelayerError::Internal( - "Fee amount too large for payment operation (exceeds i64::MAX)".to_string(), - ) - })?; - - let is_soroban = xdr_needs_simulation(&envelope).unwrap_or(false); - // For Soroban we don't add the fee payment operation because of Soroban limitation to allow just single operation in the transaction - if !is_soroban { - // Add fee payment operation to envelope - add_fee_payment_operation(&mut envelope, fee_token, fee_amount, relayer_address)?; - } - - Ok(envelope) -} +// ============================================================================ +// Soroban Gas Abstraction Handlers (FeeForwarder Flow with XDR-based detection) +// ============================================================================ -/// Build a transaction envelope from either XDR or operations -/// -/// This helper function is used by both quote and build methods to construct -/// a transaction envelope from either a pre-built XDR transaction or from -/// operations with a source account. -/// -/// When building from operations, this function fetches the user's current -/// sequence number from the network to ensure the transaction can be properly -/// signed and submitted by the user. -async fn build_envelope_from_request

( - transaction_xdr: Option<&String>, - operations: Option<&Vec>, - source_account: Option<&String>, - network_passphrase: &str, - provider: &P, -) -> Result +impl StellarRelayer where P: StellarProviderTrait + Send + Sync, + D: StellarDexServiceTrait + Send + Sync + 'static, + RR: Repository + RelayerRepository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + TR: Repository + TransactionRepository + Send + Sync + 'static, + J: JobProducerTrait + Send + Sync + 'static, + TCS: TransactionCounterServiceTrait + Send + Sync + 'static, + S: StellarSignTrait + Send + Sync + 'static, { - if let Some(xdr) = transaction_xdr { - parse_transaction_xdr(xdr, false) - .map_err(|e| RelayerError::Internal(format!("Failed to parse XDR: {e}"))) - } else if let Some(ops) = operations { - // Build envelope from operations - let source_account = source_account.ok_or_else(|| { + /// Quote a Soroban sponsored transaction using FeeForwarder (XDR-based detection) + /// + /// Called when transaction_xdr contains an InvokeHostFunction operation. + /// Extracts contract call details from the XDR and estimates fee. + async fn quote_soroban_from_xdr( + &self, + params: &crate::models::StellarFeeEstimateRequestParams, + soroban_info: &SorobanInvokeInfo, + ) -> Result { + debug!( + "Processing Soroban quote request for token: {}, target: {}::{}", + params.fee_token, soroban_info.target_contract, soroban_info.target_fn + ); + + let policy = self.relayer.policies.get_stellar_policy(); + + // Validate fee_payment_strategy is User + if !policy.is_user_fee_payment() { + return Err(RelayerError::ValidationError( + "Gas abstraction requires fee_payment_strategy: User".to_string(), + )); + } + + // Validate allowed token (same as classic flow) + StellarTransactionValidator::validate_allowed_token(¶ms.fee_token, &policy) + .map_err(|e| RelayerError::ValidationError(e.to_string()))?; + + // Get fee_forwarder address: env var override takes precedence, otherwise use mainnet default + let fee_forwarder = crate::config::ServerConfig::resolve_stellar_fee_forwarder_address( + self.network.is_testnet(), + ) + .ok_or_else(|| { + let env_var = if self.network.is_testnet() { + "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS" + } else { + "STELLAR_MAINNET_FEE_FORWARDER_ADDRESS" + }; + RelayerError::ValidationError(format!( + "FeeForwarder address not configured. Set {env_var} env var." + )) + })?; + + // Validate fee_token is a valid Soroban contract address (C...) + if stellar_strkey::Contract::from_string(¶ms.fee_token).is_err() { + return Err(RelayerError::ValidationError(format!( + "fee_token must be a valid Soroban contract address (C...), got '{}'", + params.fee_token + ))); + } + + // Extract user_address from transaction_xdr source account (or use source_account if provided) + // For quote, we don't need the actual user_address, just validation that XDR is valid + // The user_address will be extracted in build phase when we have the XDR + + let xdr = params.transaction_xdr.as_ref().ok_or_else(|| { RelayerError::ValidationError( - "source_account is required when providing operations".to_string(), + "Soroban gas abstraction requires transaction_xdr".to_string(), ) })?; - // Create StellarTransactionData from operations - // Fetch the user's current sequence number from the network - // This is required because the user will sign the transaction with their account - let account_entry = provider.get_account(source_account).await.map_err(|e| { - warn!( - source_account = %source_account, - error = %e, - "get_account failed in build_envelope_from_request (called before transaction creation)" - ); - // Note: We don't have relayer_id here, so we can't track the metric with relayer_id - // This is called during gas abstraction operations before transaction creation - RelayerError::Internal(format!( - "Failed to fetch account sequence number for {source_account}: {e}", - )) + let source_envelope = TransactionEnvelope::from_xdr_base64(xdr, Limits::none()) + .map_err(|e| RelayerError::ValidationError(format!("Invalid XDR: {e}")))?; + let user_address = extract_source_account(&source_envelope).map_err(|e| { + RelayerError::ValidationError(format!("Failed to extract source account: {e}")) })?; - // Use the next sequence number (current + 1) - let next_sequence = account_entry.seq_num.0 + 1; + // Build FeeForwarder params with a placeholder fee (will simulate to get accurate fee) + let base_fee_stroops: u64 = STELLAR_DEFAULT_TRANSACTION_FEE as u64; + let base_fee_quote = convert_xlm_fee_to_token( + self.dex_service.as_ref(), + &policy, + base_fee_stroops, + ¶ms.fee_token, + ) + .await + .map_err(crate::models::RelayerError::from)?; - let stellar_data = StellarTransactionData { - source_account: source_account.clone(), - fee: None, - sequence_number: Some(next_sequence as i64), - memo: None, - valid_until: None, - network_passphrase: network_passphrase.to_string(), - signatures: vec![], - hash: None, - simulation_transaction_data: None, - transaction_input: TransactionInput::Operations(ops.clone()), - signed_envelope_xdr: None, - transaction_result_xdr: None, + let validity_duration = get_stellar_sponsored_transaction_validity_duration(); + let validity_seconds = validity_duration.num_seconds() as u64; + let expiration_ledger = get_expiration_ledger(&self.provider, validity_seconds) + .await + .map_err(|e| RelayerError::Internal(format!("Failed to get expiration ledger: {e}")))?; + + let fee_params = FeeForwarderParams { + fee_token: params.fee_token.clone(), + fee_amount: base_fee_quote.fee_in_token as i128, + max_fee_amount: apply_max_fee_slippage(base_fee_quote.fee_in_token), + expiration_ledger, + target_contract: soroban_info.target_contract.clone(), + target_fn: soroban_info.target_fn.clone(), + target_args: soroban_info.target_args.clone(), + user: user_address, + relayer: self.relayer.address.clone(), }; - // Build unsigned envelope from operations - stellar_data.build_unsigned_envelope().map_err(|e| { - RelayerError::Internal(format!("Failed to build envelope from operations: {e}")) - }) - } else { - Err(RelayerError::ValidationError( - "Must provide either transaction_xdr or operations in the request".to_string(), - )) + // For quote/simulation, we don't include auth entries because the FeeForwarder + // contract has custom auth verification that fails on empty signatures. + // Unlike standard Soroban "recording mode", this contract explicitly checks + // for valid signatures and returns Error when none are found. + // The build flow will include proper auth entries for accurate resource estimation. + let invoke_op = FeeForwarderService::

::build_invoke_operation_standalone( + &fee_forwarder, + &fee_params, + vec![], + ) + .map_err(|e| RelayerError::Internal(format!("Failed to build invoke operation: {e}")))?; + + let envelope = build_soroban_transaction_envelope( + &self.relayer.address, + invoke_op, + base_fee_stroops as u32, + )?; + + let sim_response = self + .provider + .simulate_transaction_envelope(&envelope) + .await + .map_err(|e| RelayerError::Internal(format!("Failed to simulate transaction: {e}")))?; + + let total_fee = calculate_total_soroban_fee(&sim_response, 1)?; + + let fee_quote = convert_xlm_fee_to_token( + self.dex_service.as_ref(), + &policy, + total_fee as u64, + ¶ms.fee_token, + ) + .await + .map_err(crate::models::RelayerError::from)?; + + debug!( + "Soroban fee estimate: {} stroops, {} token", + fee_quote.fee_in_stroops, fee_quote.fee_in_token + ); + + // Validate max fee + StellarTransactionValidator::validate_max_fee(fee_quote.fee_in_stroops, &policy) + .map_err(|e| RelayerError::ValidationError(e.to_string()))?; + + // Validate token-specific max fee + StellarTransactionValidator::validate_token_max_fee( + ¶ms.fee_token, + fee_quote.fee_in_token, + &policy, + ) + .map_err(|e| RelayerError::ValidationError(e.to_string()))?; + + // Check user token balance using the original source envelope (user as source) + StellarTransactionValidator::validate_user_token_balance( + &source_envelope, + ¶ms.fee_token, + fee_quote.fee_in_token, + &self.provider, + ) + .await + .map_err(|e| RelayerError::ValidationError(e.to_string()))?; + + // Calculate max_fee with slippage buffer for Soroban + let max_fee_in_token = apply_max_fee_slippage(fee_quote.fee_in_token); + let token_decimals = policy + .get_allowed_token_decimals(¶ms.fee_token) + .unwrap_or(7); + let max_fee_in_token_ui = amount_to_ui_amount(max_fee_in_token as u64, token_decimals); + + // Return using consolidated result struct + let result = StellarFeeEstimateResult { + fee_in_token_ui: fee_quote.fee_in_token_ui, + fee_in_token: fee_quote.fee_in_token.to_string(), + conversion_rate: fee_quote.conversion_rate.to_string(), + max_fee_in_token: Some(max_fee_in_token.to_string()), + max_fee_in_token_ui: Some(max_fee_in_token_ui), + }; + + Ok(SponsoredTransactionQuoteResponse::Stellar(result)) + } + + /// Build a Soroban sponsored transaction using FeeForwarder (XDR-based detection) + /// + /// Called when transaction_xdr contains an InvokeHostFunction operation. + /// Builds the FeeForwarder transaction wrapping the original contract call. + async fn build_soroban_sponsored( + &self, + params: &crate::models::StellarPrepareTransactionRequestParams, + soroban_info: &SorobanInvokeInfo, + ) -> Result { + debug!( + "Processing Soroban build request for token: {}, target: {}::{}", + params.fee_token, soroban_info.target_contract, soroban_info.target_fn + ); + + let policy = self.relayer.policies.get_stellar_policy(); + + // Note: validate_allowed_token is already called in build_sponsored_transaction + + // Get fee_forwarder address: env var override takes precedence, otherwise use mainnet default + let fee_forwarder = crate::config::ServerConfig::resolve_stellar_fee_forwarder_address( + self.network.is_testnet(), + ) + .ok_or_else(|| { + let env_var = if self.network.is_testnet() { + "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS" + } else { + "STELLAR_MAINNET_FEE_FORWARDER_ADDRESS" + }; + RelayerError::ValidationError(format!( + "FeeForwarder address not configured. Set {env_var} env var." + )) + })?; + + // Validate fee_token is a valid Soroban contract address (C...) + if stellar_strkey::Contract::from_string(¶ms.fee_token).is_err() { + return Err(RelayerError::ValidationError(format!( + "fee_token must be a valid Soroban contract address (C...), got '{}'", + params.fee_token + ))); + } + + // Extract user_address from transaction_xdr source account + // Soroban gas abstraction requires transaction_xdr, so we can unwrap here + let xdr = params.transaction_xdr.as_ref().ok_or_else(|| { + RelayerError::ValidationError( + "Soroban gas abstraction requires transaction_xdr".to_string(), + ) + })?; + + let source_envelope = TransactionEnvelope::from_xdr_base64(xdr, Limits::none()) + .map_err(|e| RelayerError::ValidationError(format!("Invalid XDR: {e}")))?; + let user_address = extract_source_account(&source_envelope).map_err(|e| { + RelayerError::ValidationError(format!("Failed to extract source account: {e}")) + })?; + + // Use default validity duration (same as classic sponsored transactions) + let validity_duration = get_stellar_sponsored_transaction_validity_duration(); + let validity_seconds = validity_duration.num_seconds() as u64; + let expiration_ledger = get_expiration_ledger(&self.provider, validity_seconds) + .await + .map_err(|e| RelayerError::Internal(format!("Failed to get expiration ledger: {e}")))?; + + // Build initial fee quote based on base fee, then simulate to get accurate Soroban fee + let base_fee_stroops: u64 = STELLAR_DEFAULT_TRANSACTION_FEE as u64; + let base_fee_quote = convert_xlm_fee_to_token( + self.dex_service.as_ref(), + &policy, + base_fee_stroops, + ¶ms.fee_token, + ) + .await + .map_err(crate::models::RelayerError::from)?; + + // Build the FeeForwarder parameters using extracted Soroban info + let mut fee_params = FeeForwarderParams { + fee_token: params.fee_token.clone(), + fee_amount: base_fee_quote.fee_in_token as i128, + max_fee_amount: apply_max_fee_slippage(base_fee_quote.fee_in_token), + expiration_ledger, + target_contract: soroban_info.target_contract.clone(), + target_fn: soroban_info.target_fn.clone(), + target_args: soroban_info.target_args.clone(), + user: user_address.clone(), + relayer: self.relayer.address.clone(), + }; + + // For simulation, we don't include auth entries because the FeeForwarder + // contract has custom auth verification that fails on empty signatures. + // Unlike standard Soroban "recording mode", this contract explicitly checks + // for valid signatures and returns Error when none are found. + let invoke_op = FeeForwarderService::

::build_invoke_operation_standalone( + &fee_forwarder, + &fee_params, + vec![], // Empty auth entries for simulation + ) + .map_err(|e| RelayerError::Internal(format!("Failed to build invoke operation: {e}")))?; + + let envelope = build_soroban_transaction_envelope( + &self.relayer.address, + invoke_op, + base_fee_stroops as u32, + )?; + + let sim_response = self + .provider + .simulate_transaction_envelope(&envelope) + .await + .map_err(|e| RelayerError::Internal(format!("Failed to simulate transaction: {e}")))?; + + let total_fee = calculate_total_soroban_fee(&sim_response, 1)?; + + let fee_quote = convert_xlm_fee_to_token( + self.dex_service.as_ref(), + &policy, + total_fee as u64, + ¶ms.fee_token, + ) + .await + .map_err(crate::models::RelayerError::from)?; + + // Validate max fee + StellarTransactionValidator::validate_max_fee(fee_quote.fee_in_stroops, &policy) + .map_err(|e| RelayerError::ValidationError(e.to_string()))?; + + // Validate token-specific max fee + StellarTransactionValidator::validate_token_max_fee( + ¶ms.fee_token, + fee_quote.fee_in_token, + &policy, + ) + .map_err(|e| RelayerError::ValidationError(e.to_string()))?; + + // Check user token balance using the original source envelope (user as source) + StellarTransactionValidator::validate_user_token_balance( + &source_envelope, + ¶ms.fee_token, + fee_quote.fee_in_token, + &self.provider, + ) + .await + .map_err(|e| RelayerError::ValidationError(e.to_string()))?; + + // Rebuild params with the final fee amounts + // Apply slippage buffer to max_fee_amount to allow for fee fluctuation + fee_params.fee_amount = fee_quote.fee_in_token as i128; + fee_params.max_fee_amount = apply_max_fee_slippage(fee_quote.fee_in_token); + + // Build the user authorization entry for the user to sign + let user_auth_entry = FeeForwarderService::

::build_user_auth_entry_standalone( + &fee_forwarder, + &fee_params, + true, + ) + .map_err(|e| RelayerError::Internal(format!("Failed to build user auth entry: {e}")))?; + + let user_auth_xdr = FeeForwarderService::

::serialize_auth_entry(&user_auth_entry) + .map_err(|e| RelayerError::Internal(format!("Failed to serialize auth entry: {e}")))?; + + // Build relayer auth entry - required by FeeForwarder contract + let relayer_auth_entry = FeeForwarderService::

::build_relayer_auth_entry_standalone( + &fee_forwarder, + &fee_params, + ) + .map_err(|e| RelayerError::Internal(format!("Failed to build relayer auth entry: {e}")))?; + + // Build the final invoke operation WITH auth entries + // Note: We don't simulate again because the contract's custom auth verification + // would fail on empty signatures. We use the simulation data from the first + // simulation (without auth entries) to set the fee and resources. + let invoke_op = FeeForwarderService::

::build_invoke_operation_standalone( + &fee_forwarder, + &fee_params, + vec![user_auth_entry, relayer_auth_entry], + ) + .map_err(|e| RelayerError::Internal(format!("Failed to build invoke operation: {e}")))?; + + let mut envelope = build_soroban_transaction_envelope( + &self.relayer.address, + invoke_op, + base_fee_stroops as u32, + )?; + + // Apply simulation data from the first simulation (without auth entries) + // This sets the fee and Soroban resource data on the final envelope + // Also extends the footprint to include the relayer's account for require_auth + apply_simulation_to_soroban_envelope(&mut envelope, &sim_response, 1)?; + + let transaction_xdr = envelope + .to_xdr_base64(Limits::none()) + .map_err(|e| RelayerError::Internal(format!("Failed to serialize transaction: {e}")))?; + + // Derive valid_until from expiration_ledger to ensure consistency + // Get current ledger to calculate time until expiration + let current_ledger = + self.provider.get_latest_ledger().await.map_err(|e| { + RelayerError::Internal(format!("Failed to get current ledger: {e}")) + })?; + let ledgers_until_expiration = expiration_ledger.saturating_sub(current_ledger.sequence); + let seconds_until_expiration = + ledgers_until_expiration as u64 * STELLAR_LEDGER_TIME_SECONDS; + let valid_until = Utc::now() + chrono::Duration::seconds(seconds_until_expiration as i64); + + debug!( + "Soroban build complete: transaction_xdr length={}, auth_xdr length={}, expiration_ledger={}, valid_until={}", + transaction_xdr.len(), + user_auth_xdr.len(), + expiration_ledger, + valid_until.to_rfc3339() + ); + + // Calculate max_fee with slippage buffer for Soroban + let max_fee_in_token = fee_params.max_fee_amount; + let token_decimals = policy + .get_allowed_token_decimals(¶ms.fee_token) + .unwrap_or(7); + let max_fee_in_token_ui = amount_to_ui_amount(max_fee_in_token as u64, token_decimals); + + // Return using consolidated result struct with Soroban-specific fields populated + let result = StellarPrepareTransactionResult { + transaction: transaction_xdr, + fee_in_token: fee_quote.fee_in_token.to_string(), + fee_in_token_ui: fee_quote.fee_in_token_ui, + fee_in_stroops: fee_quote.fee_in_stroops.to_string(), + fee_token: params.fee_token.clone(), + valid_until: valid_until.to_rfc3339(), + // Soroban-specific fields + user_auth_entry: Some(user_auth_xdr), + max_fee_in_token: Some(max_fee_in_token.to_string()), + max_fee_in_token_ui: Some(max_fee_in_token_ui), + }; + + Ok(SponsoredTransactionBuildResponse::Stellar(result)) } } -/// Add a fee payment operation to the transaction envelope -fn add_fee_payment_operation( +/// Build a Soroban transaction envelope with the given operation +fn build_soroban_transaction_envelope( + source_address: &str, + operation: Operation, + fee: u32, +) -> Result { + use soroban_rs::xdr::{ + Memo, MuxedAccount, Preconditions, SequenceNumber, Transaction, TransactionExt, + TransactionV1Envelope, Uint256, VecM, + }; + + // Parse source address + let pk = stellar_strkey::ed25519::PublicKey::from_string(source_address) + .map_err(|e| RelayerError::ValidationError(format!("Invalid source address: {e}")))?; + let source = MuxedAccount::Ed25519(Uint256(pk.0)); + + // Build transaction with placeholder sequence (0) - will be updated at submit time + let tx = Transaction { + source_account: source, + fee, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![operation].try_into().map_err(|_| { + RelayerError::Internal("Failed to create operations vector".to_string()) + })?, + ext: TransactionExt::V0, + }; + + Ok(TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + })) +} + +/// Calculate total fee for a Soroban transaction from simulation response. +fn calculate_total_soroban_fee( + sim_response: &soroban_rs::stellar_rpc_client::SimulateTransactionResponse, + operations_count: u64, +) -> Result { + if let Some(err) = sim_response.error.clone() { + return Err(RelayerError::ValidationError(format!( + "Simulation failed: {err}" + ))); + } + + let inclusion_fee = operations_count * STELLAR_DEFAULT_TRANSACTION_FEE as u64; + let resource_fee = sim_response.min_resource_fee; + let total_fee = inclusion_fee + resource_fee; + let total_fee_u32 = u32::try_from(total_fee) + .map_err(|_| RelayerError::Internal("Soroban fee exceeds u32::MAX".to_string()))?; + + Ok(total_fee_u32.max(STELLAR_DEFAULT_TRANSACTION_FEE)) +} + +/// Apply Soroban simulation data to a transaction envelope (fee + extension data). +fn apply_simulation_to_soroban_envelope( envelope: &mut TransactionEnvelope, - fee_token: &str, - fee_amount: i64, - relayer_address: &str, + sim_response: &soroban_rs::stellar_rpc_client::SimulateTransactionResponse, + operations_count: u64, ) -> Result<(), RelayerError> { - let payment_op_spec = create_fee_payment_operation(relayer_address, fee_token, fee_amount) - .map_err(crate::models::RelayerError::from)?; + use soroban_rs::xdr::SorobanTransactionData; - // Convert OperationSpec to XDR Operation - let payment_op = Operation::try_from(payment_op_spec) - .map_err(|e| RelayerError::Internal(format!("Failed to convert payment operation: {e}")))?; + let total_fee = calculate_total_soroban_fee(sim_response, operations_count)?; - // Add payment operation to transaction - add_operation_to_envelope(envelope, payment_op).map_err(crate::models::RelayerError::from)?; + let tx_data = SorobanTransactionData::from_xdr_base64( + sim_response.transaction_data.as_str(), + Limits::none(), + ) + .map_err(|e| RelayerError::Internal(format!("Invalid transaction_data XDR: {e}")))?; + + match envelope { + TransactionEnvelope::Tx(ref mut env) => { + env.tx.fee = total_fee; + env.tx.ext = soroban_rs::xdr::TransactionExt::V1(tx_data); + } + TransactionEnvelope::TxV0(_) | TransactionEnvelope::TxFeeBump(_) => { + return Err(RelayerError::Internal( + "Soroban transaction must be a V1 envelope".to_string(), + )); + } + } Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::transaction::stellar::utils::parse_account_id; - use crate::services::stellar_dex::AssetType; - use crate::{ - config::{NetworkConfigCommon, StellarNetworkConfig}, - jobs::MockJobProducerTrait, - models::{ - transaction::stellar::OperationSpec, AssetSpec, NetworkConfigData, NetworkRepoModel, - NetworkType, RelayerNetworkPolicy, RelayerRepoModel, RelayerStellarPolicy, RpcConfig, - SponsoredTransactionBuildRequest, SponsoredTransactionQuoteRequest, - }, - repositories::{ - InMemoryNetworkRepository, MockRelayerRepository, MockTransactionRepository, - }, - services::{ - provider::MockStellarProviderTrait, signer::MockStellarSignTrait, - stellar_dex::MockStellarDexServiceTrait, MockTransactionCounterServiceTrait, - }, - }; - use mockall::predicate::*; - use soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse; - use soroban_rs::stellar_rpc_client::LedgerEntryResult; - use soroban_rs::xdr::{ - AccountEntry, AccountEntryExt, AccountId, AlphaNum4, AssetCode4, LedgerEntry, - LedgerEntryData, LedgerEntryExt, LedgerKey, Limits, MuxedAccount, Operation, OperationBody, - PaymentOp, Preconditions, PublicKey, SequenceNumber, String32, Thresholds, Transaction, - TransactionEnvelope, TransactionExt, TransactionV1Envelope, TrustLineEntry, - TrustLineEntryExt, Uint256, VecM, WriteXdr, - }; - use std::future::ready; - use std::sync::Arc; - use stellar_strkey::ed25519::PublicKey as Ed25519PublicKey; +/// Add payment operation to envelope using a pre-computed fee quote +/// +/// This function adds a fee payment operation to the transaction envelope using +/// a pre-computed FeeQuote. This avoids duplicate DEX calls and ensures the +/// validated fee quote matches the fee amount in the payment operation. +/// +/// Note: Time bounds should be set separately just before returning the transaction +/// to give the user maximum time to review and submit. +/// +/// # Arguments +/// * `envelope` - The transaction envelope to add the payment operation to +/// * `fee_quote` - Pre-computed fee quote containing the token amount to charge +/// * `fee_token` - Asset identifier for the fee token +/// * `relayer_address` - Address of the relayer receiving the fee payment +/// +/// # Returns +/// The updated envelope with the payment operation added (if not Soroban) +fn add_payment_operation_to_envelope( + mut envelope: TransactionEnvelope, + fee_quote: &FeeQuote, + fee_token: &str, + relayer_address: &str, +) -> Result { + // Convert fee amount to i64 for payment operation + let fee_amount = i64::try_from(fee_quote.fee_in_token).map_err(|_| { + RelayerError::Internal( + "Fee amount too large for payment operation (exceeds i64::MAX)".to_string(), + ) + })?; + + let is_soroban = xdr_needs_simulation(&envelope).unwrap_or(false); + // For Soroban we don't add the fee payment operation because of Soroban limitation to allow just single operation in the transaction + if !is_soroban { + // Add fee payment operation to envelope + add_fee_payment_operation(&mut envelope, fee_token, fee_amount, relayer_address)?; + } + + Ok(envelope) +} + +/// Build a transaction envelope from either XDR or operations +/// +/// This helper function is used by both quote and build methods to construct +/// a transaction envelope from either a pre-built XDR transaction or from +/// operations with a source account. +/// +/// When building from operations, this function fetches the user's current +/// sequence number from the network to ensure the transaction can be properly +/// signed and submitted by the user. +async fn build_envelope_from_request

( + transaction_xdr: Option<&String>, + operations: Option<&Vec>, + source_account: Option<&String>, + network_passphrase: &str, + provider: &P, +) -> Result +where + P: StellarProviderTrait + Send + Sync, +{ + if let Some(xdr) = transaction_xdr { + parse_transaction_xdr(xdr, false) + .map_err(|e| RelayerError::Internal(format!("Failed to parse XDR: {e}"))) + } else if let Some(ops) = operations { + // Build envelope from operations + let source_account = source_account.ok_or_else(|| { + RelayerError::ValidationError( + "source_account is required when providing operations".to_string(), + ) + })?; + + // Create StellarTransactionData from operations + // Fetch the user's current sequence number from the network + // This is required because the user will sign the transaction with their account + let account_entry = provider.get_account(source_account).await.map_err(|e| { + warn!( + source_account = %source_account, + error = %e, + "get_account failed in build_envelope_from_request (called before transaction creation)" + ); + // Note: We don't have relayer_id here, so we can't track the metric with relayer_id + // This is called during gas abstraction operations before transaction creation + RelayerError::Internal(format!( + "Failed to fetch account sequence number for {source_account}: {e}", + )) + })?; + + // Use the next sequence number (current + 1) + let next_sequence = account_entry.seq_num.0 + 1; + + let stellar_data = StellarTransactionData { + source_account: source_account.clone(), + fee: None, + sequence_number: Some(next_sequence as i64), + memo: None, + valid_until: None, + network_passphrase: network_passphrase.to_string(), + signatures: vec![], + hash: None, + simulation_transaction_data: None, + transaction_input: TransactionInput::Operations(ops.clone()), + signed_envelope_xdr: None, + transaction_result_xdr: None, + }; + + // Build unsigned envelope from operations + stellar_data.build_unsigned_envelope().map_err(|e| { + RelayerError::Internal(format!("Failed to build envelope from operations: {e}")) + }) + } else { + Err(RelayerError::ValidationError( + "Must provide either transaction_xdr or operations in the request".to_string(), + )) + } +} + +/// Add a fee payment operation to the transaction envelope +fn add_fee_payment_operation( + envelope: &mut TransactionEnvelope, + fee_token: &str, + fee_amount: i64, + relayer_address: &str, +) -> Result<(), RelayerError> { + let payment_op_spec = create_fee_payment_operation(relayer_address, fee_token, fee_amount) + .map_err(crate::models::RelayerError::from)?; + + // Convert OperationSpec to XDR Operation + let payment_op = Operation::try_from(payment_op_spec) + .map_err(|e| RelayerError::Internal(format!("Failed to convert payment operation: {e}")))?; + + // Add payment operation to transaction + add_operation_to_envelope(envelope, payment_op).map_err(crate::models::RelayerError::from)?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::transaction::stellar::utils::parse_account_id; + use crate::services::stellar_dex::AssetType; + use crate::{ + config::{NetworkConfigCommon, StellarNetworkConfig}, + jobs::MockJobProducerTrait, + models::{ + transaction::stellar::OperationSpec, AssetSpec, NetworkConfigData, NetworkRepoModel, + NetworkType, RelayerNetworkPolicy, RelayerRepoModel, RelayerStellarPolicy, RpcConfig, + SponsoredTransactionBuildRequest, SponsoredTransactionQuoteRequest, + }, + repositories::{ + InMemoryNetworkRepository, MockRelayerRepository, MockTransactionRepository, + }, + services::{ + provider::MockStellarProviderTrait, signer::MockStellarSignTrait, + stellar_dex::MockStellarDexServiceTrait, MockTransactionCounterServiceTrait, + }, + }; + use mockall::predicate::*; + use serial_test::serial; + use soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse; + use soroban_rs::stellar_rpc_client::LedgerEntryResult; + use soroban_rs::xdr::{ + AccountEntry, AccountEntryExt, AccountId, AlphaNum4, AssetCode4, LedgerEntry, + LedgerEntryData, LedgerEntryExt, LedgerKey, Limits, MuxedAccount, Operation, OperationBody, + PaymentOp, Preconditions, PublicKey, SequenceNumber, String32, Thresholds, Transaction, + TransactionEnvelope, TransactionExt, TransactionV1Envelope, TrustLineEntry, + TrustLineEntryExt, Uint256, VecM, WriteXdr, + }; + use std::future::ready; + use std::sync::Arc; + use stellar_strkey::ed25519::PublicKey as Ed25519PublicKey; + + const TEST_PK: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + const TEST_NETWORK_PASSPHRASE: &str = "Test SDF Network ; September 2015"; + const USDC_ASSET: &str = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + + /// Helper function to create a test transaction XDR + fn create_test_transaction_xdr() -> String { + // Use a different account than TEST_PK (relayer address) to avoid validation error + let source_pk = Ed25519PublicKey::from_string( + "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2", + ) + .unwrap(); + let dest_pk = Ed25519PublicKey::from_string( + "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ", + ) + .unwrap(); + + let payment_op = PaymentOp { + destination: MuxedAccount::Ed25519(Uint256(dest_pk.0)), + asset: soroban_rs::xdr::Asset::Native, + amount: 1000000, + }; + + let operation = Operation { + source_account: None, + body: OperationBody::Payment(payment_op), + }; + + let operations: VecM = vec![operation].try_into().unwrap(); + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)), + fee: 100, + seq_num: SequenceNumber(2), // Must be > account sequence (1) + cond: Preconditions::None, + memo: soroban_rs::xdr::Memo::None, + operations, + ext: TransactionExt::V0, + }; + + let envelope = TransactionV1Envelope { + tx, + signatures: vec![].try_into().unwrap(), + }; + + let tx_envelope = TransactionEnvelope::Tx(envelope); + tx_envelope.to_xdr_base64(Limits::none()).unwrap() + } + + /// Helper function to create a test relayer with user fee payment strategy + fn create_test_relayer_with_user_fee_strategy() -> RelayerRepoModel { + let mut policy = RelayerStellarPolicy::default(); + policy.fee_payment_strategy = Some(crate::models::StellarFeePaymentStrategy::User); + policy.allowed_tokens = Some(vec![crate::models::StellarAllowedTokensPolicy { + asset: USDC_ASSET.to_string(), + metadata: None, + max_allowed_fee: None, + swap_config: None, + }]); + + RelayerRepoModel { + id: "test-relayer-id".to_string(), + name: "Test Relayer".to_string(), + network: "testnet".to_string(), + paused: false, + network_type: NetworkType::Stellar, + signer_id: "signer-id".to_string(), + policies: RelayerNetworkPolicy::Stellar(policy), + address: TEST_PK.to_string(), + notification_id: Some("notification-id".to_string()), + system_disabled: false, + custom_rpc_urls: None, + ..Default::default() + } + } + + /// Helper function to create a mock DEX service + fn create_mock_dex_service() -> Arc { + let mut mock_dex = MockStellarDexServiceTrait::new(); + mock_dex + .expect_supported_asset_types() + .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic])); + Arc::new(mock_dex) + } + + /// Helper function to create a test network + fn create_test_network() -> NetworkRepoModel { + NetworkRepoModel { + id: "stellar:testnet".to_string(), + name: "testnet".to_string(), + network_type: NetworkType::Stellar, + config: NetworkConfigData::Stellar(StellarNetworkConfig { + common: NetworkConfigCommon { + network: "testnet".to_string(), + from: None, + rpc_urls: Some(vec![RpcConfig::new( + "https://horizon-testnet.stellar.org".to_string(), + )]), + explorer_urls: None, + average_blocktime_ms: Some(5000), + is_testnet: Some(true), + tags: None, + }, + passphrase: Some(TEST_NETWORK_PASSPHRASE.to_string()), + horizon_url: Some("https://horizon-testnet.stellar.org".to_string()), + }), + } + } + + /// Helper function to create a mainnet test network (no default FeeForwarder) + fn create_test_mainnet_network() -> NetworkRepoModel { + NetworkRepoModel { + id: "stellar:mainnet".to_string(), + name: "mainnet".to_string(), + network_type: NetworkType::Stellar, + config: NetworkConfigData::Stellar(StellarNetworkConfig { + common: NetworkConfigCommon { + network: "mainnet".to_string(), + from: None, + rpc_urls: Some(vec![RpcConfig::new( + "https://horizon.stellar.org".to_string(), + )]), + explorer_urls: None, + average_blocktime_ms: Some(5000), + is_testnet: Some(false), + tags: None, + }, + passphrase: Some("Public Global Stellar Network ; September 2015".to_string()), + horizon_url: Some("https://horizon.stellar.org".to_string()), + }), + } + } + + /// Helper function to create a Stellar relayer instance for testing + async fn create_test_relayer_instance( + relayer_model: RelayerRepoModel, + provider: MockStellarProviderTrait, + dex_service: Arc, + ) -> crate::domain::relayer::stellar::StellarRelayer< + MockStellarProviderTrait, + MockRelayerRepository, + InMemoryNetworkRepository, + MockTransactionRepository, + MockJobProducerTrait, + MockTransactionCounterServiceTrait, + MockStellarSignTrait, + MockStellarDexServiceTrait, + > { + let network_repository = Arc::new(InMemoryNetworkRepository::new()); + let test_network = create_test_network(); + network_repository.create(test_network).await.unwrap(); + + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let job_producer = Arc::new(MockJobProducerTrait::new()); + let counter = Arc::new(MockTransactionCounterServiceTrait::new()); + let signer = Arc::new(MockStellarSignTrait::new()); + + crate::domain::relayer::stellar::StellarRelayer::new( + relayer_model, + signer, + provider, + crate::domain::relayer::stellar::StellarRelayerDependencies::new( + relayer_repo, + network_repository, + tx_repo, + counter, + job_producer, + ), + dex_service, + ) + .await + .unwrap() + } + + /// Helper function to create a Stellar relayer instance with custom network for testing + async fn create_test_relayer_instance_with_network( + relayer_model: RelayerRepoModel, + provider: MockStellarProviderTrait, + dex_service: Arc, + network: NetworkRepoModel, + ) -> crate::domain::relayer::stellar::StellarRelayer< + MockStellarProviderTrait, + MockRelayerRepository, + InMemoryNetworkRepository, + MockTransactionRepository, + MockJobProducerTrait, + MockTransactionCounterServiceTrait, + MockStellarSignTrait, + MockStellarDexServiceTrait, + > { + let network_repository = Arc::new(InMemoryNetworkRepository::new()); + network_repository.create(network).await.unwrap(); + + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let job_producer = Arc::new(MockJobProducerTrait::new()); + let counter = Arc::new(MockTransactionCounterServiceTrait::new()); + let signer = Arc::new(MockStellarSignTrait::new()); + + crate::domain::relayer::stellar::StellarRelayer::new( + relayer_model, + signer, + provider, + crate::domain::relayer::stellar::StellarRelayerDependencies::new( + relayer_repo, + network_repository, + tx_repo, + counter, + job_producer, + ), + dex_service, + ) + .await + .unwrap() + } + + #[tokio::test] + async fn test_quote_sponsored_transaction_with_xdr() { + let relayer_model = create_test_relayer_with_user_fee_strategy(); + let mut provider = MockStellarProviderTrait::new(); + + // Mock account for validation + provider.expect_get_account().returning(|_| { + Box::pin(ready(Ok(AccountEntry { + account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))), + balance: 1000000000, + seq_num: SequenceNumber(1), + num_sub_entries: 0, + inflation_dest: None, + flags: 0, + home_domain: String32::default(), + thresholds: Thresholds([0; 4]), + signers: VecM::default(), + ext: AccountEntryExt::V0, + }))) + }); + + // Mock get_ledger_entries for token balance validation + // This mock extracts the account ID from the ledger key and returns a trustline with sufficient balance + provider.expect_get_ledger_entries().returning(|keys| { + // Extract account ID from the first ledger key (should be a Trustline key) + let account_id = if let Some(LedgerKey::Trustline(trustline_key)) = keys.first() { + trustline_key.account_id.clone() + } else { + // Fallback: try to parse TEST_PK + parse_account_id(TEST_PK).unwrap_or_else(|_| { + AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))) + }) + }; + + let issuer_id = + parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN") + .unwrap_or_else(|_| { + AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))) + }); + + // Create a trustline entry with sufficient balance + let trustline_entry = TrustLineEntry { + account_id, + asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 { + asset_code: AssetCode4(*b"USDC"), + issuer: issuer_id, + }), + balance: 10_000_000i64, + limit: i64::MAX, + flags: 0, + ext: TrustLineEntryExt::V0, + }; + + let ledger_entry = LedgerEntry { + last_modified_ledger_seq: 0, + data: LedgerEntryData::Trustline(trustline_entry), + ext: LedgerEntryExt::V0, + }; + + // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry) + let xdr = ledger_entry + .data + .to_xdr_base64(soroban_rs::xdr::Limits::none()) + .expect("Failed to encode trustline entry data to XDR"); + + Box::pin(ready(Ok(GetLedgerEntriesResponse { + entries: Some(vec![LedgerEntryResult { + key: "test_key".to_string(), + xdr, + last_modified_ledger: 0u32, + live_until_ledger_seq_ledger_seq: None, + }]), + latest_ledger: 0, + }))) + }); + + let mut dex_service = MockStellarDexServiceTrait::new(); + dex_service + .expect_supported_asset_types() + .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic])); + + // Mock get_xlm_to_token_quote for fee conversion (XLM -> token) + dex_service + .expect_get_xlm_to_token_quote() + .returning(|_, _, _, _| { + Box::pin(ready(Ok( + crate::services::stellar_dex::StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: USDC_ASSET.to_string(), + in_amount: 100000, + out_amount: 1500000, + price_impact_pct: 0.0, + slippage_bps: 100, + path: None, + }, + ))) + }); + + let dex_service = Arc::new(dex_service); + let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; + + let transaction_xdr = create_test_transaction_xdr(); + let request = SponsoredTransactionQuoteRequest::Stellar( + crate::models::StellarFeeEstimateRequestParams { + transaction_xdr: Some(transaction_xdr), + operations: None, + source_account: None, + fee_token: USDC_ASSET.to_string(), + }, + ); + + let result = relayer.quote_sponsored_transaction(request).await; + if let Err(e) = &result { + eprintln!("Quote error: {:?}", e); + } + assert!(result.is_ok()); + + if let SponsoredTransactionQuoteResponse::Stellar(quote) = result.unwrap() { + assert_eq!(quote.fee_in_token, "1500000"); + assert!(!quote.fee_in_token_ui.is_empty()); + assert!(!quote.conversion_rate.is_empty()); + } else { + panic!("Expected Stellar quote response"); + } + } + + #[tokio::test] + async fn test_quote_sponsored_transaction_with_operations() { + let relayer_model = create_test_relayer_with_user_fee_strategy(); + let mut provider = MockStellarProviderTrait::new(); + + provider.expect_get_account().returning(|_| { + Box::pin(ready(Ok(AccountEntry { + account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))), + balance: 1000000000, + seq_num: SequenceNumber(-1), + num_sub_entries: 0, + inflation_dest: None, + flags: 0, + home_domain: String32::default(), + thresholds: Thresholds([0; 4]), + signers: VecM::default(), + ext: AccountEntryExt::V0, + }))) + }); + + // Mock get_ledger_entries for token balance validation + // This mock extracts the account ID from the ledger key and returns a trustline with sufficient balance + provider.expect_get_ledger_entries().returning(|keys| { + // Extract account ID from the first ledger key (should be a Trustline key) + let account_id = if let Some(LedgerKey::Trustline(trustline_key)) = keys.first() { + trustline_key.account_id.clone() + } else { + // Fallback: use the source account from the test + parse_account_id("GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2") + .unwrap_or_else(|_| { + AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))) + }) + }; + + let issuer_id = + parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN") + .unwrap_or_else(|_| { + AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))) + }); + + // Create a trustline entry with sufficient balance + let trustline_entry = TrustLineEntry { + account_id, + asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 { + asset_code: AssetCode4(*b"USDC"), + issuer: issuer_id, + }), + balance: 10_000_000i64, + limit: i64::MAX, + flags: 0, + ext: TrustLineEntryExt::V0, + }; + + let ledger_entry = LedgerEntry { + last_modified_ledger_seq: 0, + data: LedgerEntryData::Trustline(trustline_entry), + ext: LedgerEntryExt::V0, + }; + + // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry) + let xdr = ledger_entry + .data + .to_xdr_base64(soroban_rs::xdr::Limits::none()) + .expect("Failed to encode trustline entry data to XDR"); + + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse { + entries: Some(vec![LedgerEntryResult { + key: "test_key".to_string(), + xdr, + last_modified_ledger: 0u32, + live_until_ledger_seq_ledger_seq: None, + }]), + latest_ledger: 0, + }, + ))) + }); + + let mut dex_service = MockStellarDexServiceTrait::new(); + dex_service + .expect_supported_asset_types() + .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic])); + + // Mock get_xlm_to_token_quote for fee conversion (XLM -> token) + dex_service + .expect_get_xlm_to_token_quote() + .returning(|_, _, _, _| { + Box::pin(ready(Ok( + crate::services::stellar_dex::StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: USDC_ASSET.to_string(), + in_amount: 100000, + out_amount: 1500000, + price_impact_pct: 0.0, + slippage_bps: 100, + path: None, + }, + ))) + }); + + let dex_service = Arc::new(dex_service); + let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; + + let operations = vec![OperationSpec::Payment { + destination: TEST_PK.to_string(), + amount: 1000000, + asset: AssetSpec::Native, + }]; + + let request = SponsoredTransactionQuoteRequest::Stellar( + crate::models::StellarFeeEstimateRequestParams { + transaction_xdr: None, + operations: Some(operations), + source_account: Some( + "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2".to_string(), + ), + fee_token: USDC_ASSET.to_string(), + }, + ); + + let result = relayer.quote_sponsored_transaction(request).await; + if let Err(e) = &result { + eprintln!("Quote error: {:?}", e); + } + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_quote_sponsored_transaction_invalid_token() { + let relayer_model = create_test_relayer_with_user_fee_strategy(); + let provider = MockStellarProviderTrait::new(); + let dex_service = create_mock_dex_service(); + let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; + + let transaction_xdr = create_test_transaction_xdr(); + let request = SponsoredTransactionQuoteRequest::Stellar( + crate::models::StellarFeeEstimateRequestParams { + transaction_xdr: Some(transaction_xdr), + operations: None, + source_account: None, + fee_token: "INVALID:TOKEN".to_string(), + }, + ); + + let result = relayer.quote_sponsored_transaction(request).await; + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + RelayerError::ValidationError(_) + )); + } + + #[tokio::test] + async fn test_quote_sponsored_transaction_missing_xdr_and_operations() { + let relayer_model = create_test_relayer_with_user_fee_strategy(); + let provider = MockStellarProviderTrait::new(); + let dex_service = create_mock_dex_service(); + let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; + + let request = SponsoredTransactionQuoteRequest::Stellar( + crate::models::StellarFeeEstimateRequestParams { + transaction_xdr: None, + operations: None, + source_account: None, + fee_token: USDC_ASSET.to_string(), + }, + ); + + let result = relayer.quote_sponsored_transaction(request).await; + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + RelayerError::ValidationError(_) + )); + } + + #[tokio::test] + async fn test_build_sponsored_transaction_with_xdr() { + let relayer_model = create_test_relayer_with_user_fee_strategy(); + let mut provider = MockStellarProviderTrait::new(); + + provider.expect_get_account().returning(|_| { + Box::pin(ready(Ok(AccountEntry { + account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))), + balance: 1000000000, + seq_num: SequenceNumber(-1), + num_sub_entries: 0, + inflation_dest: None, + flags: 0, + home_domain: String32::default(), + thresholds: Thresholds([0; 4]), + signers: VecM::default(), + ext: AccountEntryExt::V0, + }))) + }); + + // Mock get_ledger_entries for token balance validation + // This mock extracts the account ID from the ledger key and returns a trustline with sufficient balance + provider.expect_get_ledger_entries().returning(|keys| { + // Extract account ID from the first ledger key (should be a Trustline key) + let account_id = if let Some(LedgerKey::Trustline(trustline_key)) = keys.first() { + trustline_key.account_id.clone() + } else { + // Fallback: try to parse TEST_PK + parse_account_id(TEST_PK).unwrap_or_else(|_| { + AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))) + }) + }; + + let issuer_id = + parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN") + .unwrap_or_else(|_| { + AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))) + }); + + // Create a trustline entry with sufficient balance (10 USDC = 10000000 with 6 decimals) + let trustline_entry = TrustLineEntry { + account_id, + asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 { + asset_code: AssetCode4(*b"USDC"), + issuer: issuer_id, + }), + balance: 10_000_000i64, // 10 USDC (with 6 decimals) - sufficient for fee + limit: i64::MAX, + flags: 0, + ext: TrustLineEntryExt::V0, // V0 has no liabilities + }; + + let ledger_entry = LedgerEntry { + last_modified_ledger_seq: 0, + data: LedgerEntryData::Trustline(trustline_entry), + ext: LedgerEntryExt::V0, + }; + + // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry) + let xdr = ledger_entry + .data + .to_xdr_base64(soroban_rs::xdr::Limits::none()) + .expect("Failed to encode trustline entry data to XDR"); + + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse { + entries: Some(vec![LedgerEntryResult { + key: "test_key".to_string(), + xdr, + last_modified_ledger: 0u32, + live_until_ledger_seq_ledger_seq: None, + }]), + latest_ledger: 0, + }, + ))) + }); + + let mut dex_service = MockStellarDexServiceTrait::new(); + dex_service + .expect_supported_asset_types() + .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic])); + + // Mock get_xlm_to_token_quote for build (converting XLM fee to token) + dex_service + .expect_get_xlm_to_token_quote() + .returning(|_, _, _, _| { + Box::pin(ready(Ok( + crate::services::stellar_dex::StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: USDC_ASSET.to_string(), + in_amount: 1000000, + out_amount: 1500000, + price_impact_pct: 0.0, + slippage_bps: 100, + path: None, + }, + ))) + }); + + let dex_service = Arc::new(dex_service); + let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; + + let transaction_xdr = create_test_transaction_xdr(); + let request = SponsoredTransactionBuildRequest::Stellar( + crate::models::StellarPrepareTransactionRequestParams { + transaction_xdr: Some(transaction_xdr), + operations: None, + source_account: None, + fee_token: USDC_ASSET.to_string(), + }, + ); + + let result = relayer.build_sponsored_transaction(request).await; + assert!(result.is_ok()); + + if let SponsoredTransactionBuildResponse::Stellar(build) = result.unwrap() { + assert!(!build.transaction.is_empty()); + assert_eq!(build.fee_in_token, "1500000"); + assert!(!build.fee_in_token_ui.is_empty()); + assert_eq!(build.fee_token, USDC_ASSET); + assert!(!build.valid_until.is_empty()); + } else { + panic!("Expected Stellar build response"); + } + } + + #[tokio::test] + async fn test_build_sponsored_transaction_with_operations() { + let relayer_model = create_test_relayer_with_user_fee_strategy(); + let mut provider = MockStellarProviderTrait::new(); + + provider.expect_get_account().returning(|_| { + Box::pin(ready(Ok(AccountEntry { + account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))), + balance: 1000000000, + seq_num: SequenceNumber(-1), + num_sub_entries: 0, + inflation_dest: None, + flags: 0, + home_domain: String32::default(), + thresholds: Thresholds([0; 4]), + signers: VecM::default(), + ext: AccountEntryExt::V0, + }))) + }); + + provider.expect_get_ledger_entries().returning(|_| { + use crate::domain::transaction::stellar::utils::parse_account_id; + use soroban_rs::stellar_rpc_client::LedgerEntryResult; + use soroban_rs::xdr::{ + AccountId, AlphaNum4, AssetCode4, LedgerEntry, LedgerEntryData, LedgerEntryExt, + PublicKey, TrustLineEntry, TrustLineEntryExt, Uint256, WriteXdr, + }; + + // Parse account IDs - use the source account from the test + let account_id = + parse_account_id("GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2") + .unwrap_or_else(|_| { + AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))) + }); + let issuer_id = + parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN") + .unwrap_or_else(|_| { + AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))) + }); + + // Create a trustline entry with sufficient balance (10 USDC = 10000000 with 6 decimals) + // The fee is 1500000 (from the quote), so 10 USDC is more than enough + let trustline_entry = TrustLineEntry { + account_id, + asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 { + asset_code: AssetCode4(*b"USDC"), + issuer: issuer_id, + }), + balance: 10_000_000i64, + limit: i64::MAX, + flags: 0, + ext: TrustLineEntryExt::V0, + }; + + let ledger_entry = LedgerEntry { + last_modified_ledger_seq: 0, + data: LedgerEntryData::Trustline(trustline_entry), + ext: LedgerEntryExt::V0, + }; + + // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry) + // The parse_ledger_entry_from_xdr function expects just the data portion + let xdr = ledger_entry + .data + .to_xdr_base64(soroban_rs::xdr::Limits::none()) + .expect("Failed to encode trustline entry data to XDR"); + + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse { + entries: Some(vec![LedgerEntryResult { + key: "test_key".to_string(), + xdr, + last_modified_ledger: 0u32, + live_until_ledger_seq_ledger_seq: None, + }]), + latest_ledger: 0, + }, + ))) + }); + + let mut dex_service = MockStellarDexServiceTrait::new(); + dex_service + .expect_supported_asset_types() + .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic])); + + dex_service + .expect_get_xlm_to_token_quote() + .returning(|_, _, _, _| { + Box::pin(ready(Ok( + crate::services::stellar_dex::StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: USDC_ASSET.to_string(), + in_amount: 1000000, + out_amount: 1500000, + price_impact_pct: 0.0, + slippage_bps: 100, + path: None, + }, + ))) + }); + + let dex_service = Arc::new(dex_service); + let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; + + let operations = vec![OperationSpec::Payment { + destination: TEST_PK.to_string(), + amount: 1000000, + asset: AssetSpec::Native, + }]; + + let request = SponsoredTransactionBuildRequest::Stellar( + crate::models::StellarPrepareTransactionRequestParams { + transaction_xdr: None, + operations: Some(operations), + source_account: Some( + "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2".to_string(), + ), + fee_token: USDC_ASSET.to_string(), + }, + ); + + let result = relayer.build_sponsored_transaction(request).await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_build_sponsored_transaction_missing_source_account() { + let relayer_model = create_test_relayer_with_user_fee_strategy(); + let provider = MockStellarProviderTrait::new(); + let dex_service = create_mock_dex_service(); + let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; + + let operations = vec![OperationSpec::Payment { + destination: TEST_PK.to_string(), + amount: 1000000, + asset: AssetSpec::Native, + }]; + + let request = SponsoredTransactionBuildRequest::Stellar( + crate::models::StellarPrepareTransactionRequestParams { + transaction_xdr: None, + operations: Some(operations), + source_account: None, + fee_token: USDC_ASSET.to_string(), + }, + ); + + let result = relayer.build_sponsored_transaction(request).await; + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + RelayerError::ValidationError(_) + )); + } + + #[tokio::test] + async fn test_build_envelope_from_request_with_xdr() { + let provider = MockStellarProviderTrait::new(); + let transaction_xdr = create_test_transaction_xdr(); + let result = build_envelope_from_request( + Some(&transaction_xdr), + None, + None, + TEST_NETWORK_PASSPHRASE, + &provider, + ) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_build_envelope_from_request_with_operations() { + let mut provider = MockStellarProviderTrait::new(); + + // Mock get_account to return a valid account with sequence number + provider.expect_get_account().returning(|_| { + Box::pin(ready(Ok(AccountEntry { + account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))), + balance: 1000000000, + seq_num: SequenceNumber(100), + num_sub_entries: 0, + inflation_dest: None, + flags: 0, + home_domain: String32::default(), + thresholds: Thresholds([0; 4]), + signers: VecM::default(), + ext: AccountEntryExt::V0, + }))) + }); + + let operations = vec![OperationSpec::Payment { + destination: TEST_PK.to_string(), + amount: 1000000, + asset: AssetSpec::Native, + }]; + + let result = build_envelope_from_request( + None, + Some(&operations), + Some(&TEST_PK.to_string()), + TEST_NETWORK_PASSPHRASE, + &provider, + ) + .await; + assert!(result.is_ok()); + + // Verify the sequence number is set correctly (current + 1 = 101) + if let Ok(envelope) = result { + if let TransactionEnvelope::Tx(tx_env) = envelope { + assert_eq!(tx_env.tx.seq_num.0, 101); + } + } + } + + #[tokio::test] + async fn test_build_envelope_from_request_missing_source_account() { + let provider = MockStellarProviderTrait::new(); + let operations = vec![OperationSpec::Payment { + destination: TEST_PK.to_string(), + amount: 1000000, + asset: AssetSpec::Native, + }]; + + let result = build_envelope_from_request( + None, + Some(&operations), + None, + TEST_NETWORK_PASSPHRASE, + &provider, + ) + .await; + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + RelayerError::ValidationError(_) + )); + } + + #[tokio::test] + async fn test_build_envelope_from_request_missing_both() { + let provider = MockStellarProviderTrait::new(); + let result = + build_envelope_from_request(None, None, None, TEST_NETWORK_PASSPHRASE, &provider).await; + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + RelayerError::ValidationError(_) + )); + } + + #[tokio::test] + async fn test_build_envelope_from_request_invalid_xdr() { + let provider = MockStellarProviderTrait::new(); + let result = build_envelope_from_request( + Some(&"INVALID_XDR".to_string()), + None, + None, + TEST_NETWORK_PASSPHRASE, + &provider, + ) + .await; + assert!(result.is_err()); + } + + // ============================================================================ + // Tests for detect_soroban_invoke_from_xdr + // ============================================================================ + + #[test] + fn test_detect_soroban_invoke_from_xdr_classic_transaction() { + // Classic payment transaction should return None + let xdr = create_test_transaction_xdr(); + let result = detect_soroban_invoke_from_xdr(&xdr); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[test] + fn test_detect_soroban_invoke_from_xdr_invalid_xdr() { + let result = detect_soroban_invoke_from_xdr("INVALID_XDR"); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + RelayerError::ValidationError(_) + )); + } + + #[test] + fn test_detect_soroban_invoke_from_xdr_with_soroban_transaction() { + use soroban_rs::xdr::{ + ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo, + MuxedAccount, Operation, OperationBody, Preconditions, ScAddress, ScSymbol, ScVal, + SequenceNumber, Transaction, TransactionEnvelope, TransactionExt, + TransactionV1Envelope, Uint256, VecM, + }; + + // Create a Soroban InvokeHostFunction transaction + let contract_id = ContractId(Hash([1u8; 32])); + let invoke_args = InvokeContractArgs { + contract_address: ScAddress::Contract(contract_id), + function_name: ScSymbol("test_function".try_into().unwrap()), + args: vec![ScVal::Bool(true)].try_into().unwrap(), + }; + + let invoke_op = InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(invoke_args), + auth: VecM::default(), + }; + + let operation = Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(invoke_op), + }; + + let source_pk = Ed25519PublicKey::from_string( + "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2", + ) + .unwrap(); + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)), + fee: 100, + seq_num: SequenceNumber(1), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![operation].try_into().unwrap(), + ext: TransactionExt::V0, + }; + + let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }); + + let xdr = envelope.to_xdr_base64(Limits::none()).unwrap(); + let result = detect_soroban_invoke_from_xdr(&xdr); + assert!(result.is_ok()); + + let soroban_info = result.unwrap(); + assert!(soroban_info.is_some()); + + let info = soroban_info.unwrap(); + assert_eq!(info.target_fn, "test_function"); + assert_eq!(info.target_args.len(), 1); + // Verify contract address format (C...) + assert!(info.target_contract.starts_with('C')); + } + + #[test] + fn test_detect_soroban_invoke_from_xdr_multiple_operations_error() { + use soroban_rs::xdr::{ + ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo, + MuxedAccount, Operation, OperationBody, PaymentOp, Preconditions, ScAddress, ScSymbol, + SequenceNumber, Transaction, TransactionEnvelope, TransactionExt, + TransactionV1Envelope, Uint256, VecM, + }; + + // Create a transaction with InvokeHostFunction AND another operation (invalid for Soroban) + let contract_id = ContractId(Hash([1u8; 32])); + let invoke_args = InvokeContractArgs { + contract_address: ScAddress::Contract(contract_id), + function_name: ScSymbol("test".try_into().unwrap()), + args: VecM::default(), + }; + + let invoke_op = InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(invoke_args), + auth: VecM::default(), + }; + + let source_pk = Ed25519PublicKey::from_string( + "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2", + ) + .unwrap(); + let dest_pk = Ed25519PublicKey::from_string( + "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ", + ) + .unwrap(); + + let payment_op = PaymentOp { + destination: MuxedAccount::Ed25519(Uint256(dest_pk.0)), + asset: soroban_rs::xdr::Asset::Native, + amount: 1000000, + }; + + let operations: VecM = vec![ + Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(invoke_op), + }, + Operation { + source_account: None, + body: OperationBody::Payment(payment_op), + }, + ] + .try_into() + .unwrap(); + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)), + fee: 100, + seq_num: SequenceNumber(1), + cond: Preconditions::None, + memo: Memo::None, + operations, + ext: TransactionExt::V0, + }; + + let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }); + + let xdr = envelope.to_xdr_base64(Limits::none()).unwrap(); + let result = detect_soroban_invoke_from_xdr(&xdr); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, RelayerError::ValidationError(_))); + if let RelayerError::ValidationError(msg) = err { + assert!(msg.contains("exactly one operation")); + } + } + + #[test] + fn test_detect_soroban_invoke_from_xdr_v0_envelope() { + use soroban_rs::xdr::{ + Memo, Operation, OperationBody, PaymentOp, SequenceNumber, TransactionEnvelope, + TransactionV0, TransactionV0Envelope, TransactionV0Ext, Uint256, VecM, + }; + + // Create a V0 envelope (legacy format) + let source_pk = Ed25519PublicKey::from_string( + "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2", + ) + .unwrap(); + let dest_pk = Ed25519PublicKey::from_string( + "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ", + ) + .unwrap(); + + let payment_op = PaymentOp { + destination: MuxedAccount::Ed25519(Uint256(dest_pk.0)), + asset: soroban_rs::xdr::Asset::Native, + amount: 1000000, + }; + + let tx = TransactionV0 { + source_account_ed25519: Uint256(source_pk.0), + fee: 100, + seq_num: SequenceNumber(1), + time_bounds: None, + memo: Memo::None, + operations: vec![Operation { + source_account: None, + body: OperationBody::Payment(payment_op), + }] + .try_into() + .unwrap(), + ext: TransactionV0Ext::V0, + }; + + let envelope = TransactionEnvelope::TxV0(TransactionV0Envelope { + tx, + signatures: VecM::default(), + }); - const TEST_PK: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; - const TEST_NETWORK_PASSPHRASE: &str = "Test SDF Network ; September 2015"; - const USDC_ASSET: &str = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + let xdr = envelope.to_xdr_base64(Limits::none()).unwrap(); + let result = detect_soroban_invoke_from_xdr(&xdr); + + // V0 envelope with classic operation should return None + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[test] + fn test_detect_soroban_invoke_from_xdr_fee_bump_envelope() { + use soroban_rs::xdr::{ + FeeBumpTransaction, FeeBumpTransactionEnvelope, FeeBumpTransactionExt, + FeeBumpTransactionInnerTx, Memo, MuxedAccount, Operation, OperationBody, PaymentOp, + Preconditions, SequenceNumber, Transaction, TransactionEnvelope, TransactionExt, + TransactionV1Envelope, Uint256, VecM, + }; - /// Helper function to create a test transaction XDR - fn create_test_transaction_xdr() -> String { - // Use a different account than TEST_PK (relayer address) to avoid validation error let source_pk = Ed25519PublicKey::from_string( "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2", ) @@ -501,238 +2309,460 @@ mod tests { amount: 1000000, }; - let operation = Operation { - source_account: None, - body: OperationBody::Payment(payment_op), + let inner_tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)), + fee: 100, + seq_num: SequenceNumber(1), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![Operation { + source_account: None, + body: OperationBody::Payment(payment_op), + }] + .try_into() + .unwrap(), + ext: TransactionExt::V0, }; - let operations: VecM = vec![operation].try_into().unwrap(); + let inner_envelope = TransactionV1Envelope { + tx: inner_tx, + signatures: VecM::default(), + }; + + let fee_bump_tx = FeeBumpTransaction { + fee_source: MuxedAccount::Ed25519(Uint256(source_pk.0)), + fee: 200, + inner_tx: FeeBumpTransactionInnerTx::Tx(inner_envelope), + ext: FeeBumpTransactionExt::V0, + }; + + let envelope = TransactionEnvelope::TxFeeBump(FeeBumpTransactionEnvelope { + tx: fee_bump_tx, + signatures: VecM::default(), + }); + + let xdr = envelope.to_xdr_base64(Limits::none()).unwrap(); + let result = detect_soroban_invoke_from_xdr(&xdr); + + // Fee bump with classic operation should return None + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[test] + fn test_detect_soroban_invoke_non_contract_address_error() { + use soroban_rs::xdr::{ + HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo, MuxedAccount, Operation, + OperationBody, Preconditions, ScAddress, ScSymbol, SequenceNumber, Transaction, + TransactionEnvelope, TransactionExt, TransactionV1Envelope, Uint256, VecM, + }; + + // Create a Soroban transaction with account address instead of contract address + let source_pk = Ed25519PublicKey::from_string( + "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2", + ) + .unwrap(); + + let invoke_args = InvokeContractArgs { + contract_address: ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519( + Uint256(source_pk.0), + ))), + function_name: ScSymbol("test".try_into().unwrap()), + args: VecM::default(), + }; + + let invoke_op = InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(invoke_args), + auth: VecM::default(), + }; let tx = Transaction { source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)), fee: 100, - seq_num: SequenceNumber(2), // Must be > account sequence (1) + seq_num: SequenceNumber(1), cond: Preconditions::None, - memo: soroban_rs::xdr::Memo::None, - operations, + memo: Memo::None, + operations: vec![Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(invoke_op), + }] + .try_into() + .unwrap(), ext: TransactionExt::V0, }; - let envelope = TransactionV1Envelope { + let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { tx, - signatures: vec![].try_into().unwrap(), + signatures: VecM::default(), + }); + + let xdr = envelope.to_xdr_base64(Limits::none()).unwrap(); + let result = detect_soroban_invoke_from_xdr(&xdr); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, RelayerError::ValidationError(_))); + if let RelayerError::ValidationError(msg) = err { + assert!(msg.contains("contract address")); + } + } + + // ============================================================================ + // Tests for calculate_total_soroban_fee + // ============================================================================ + + #[test] + fn test_calculate_total_soroban_fee_success() { + let sim_response = soroban_rs::stellar_rpc_client::SimulateTransactionResponse { + error: None, + transaction_data: "".to_string(), + min_resource_fee: 50000, + ..Default::default() }; - let tx_envelope = TransactionEnvelope::Tx(envelope); - tx_envelope.to_xdr_base64(Limits::none()).unwrap() + let result = calculate_total_soroban_fee(&sim_response, 1); + assert!(result.is_ok()); + // inclusion_fee (100) + resource_fee (50000) = 50100 + let fee = result.unwrap(); + assert_eq!(fee, 50100); } - /// Helper function to create a test relayer with user fee payment strategy - fn create_test_relayer_with_user_fee_strategy() -> RelayerRepoModel { - let mut policy = RelayerStellarPolicy::default(); - policy.fee_payment_strategy = Some(crate::models::StellarFeePaymentStrategy::User); - policy.allowed_tokens = Some(vec![crate::models::StellarAllowedTokensPolicy { - asset: USDC_ASSET.to_string(), - metadata: None, - max_allowed_fee: None, - swap_config: None, - }]); + #[test] + fn test_calculate_total_soroban_fee_with_multiple_operations() { + let sim_response = soroban_rs::stellar_rpc_client::SimulateTransactionResponse { + error: None, + transaction_data: "".to_string(), + min_resource_fee: 50000, + ..Default::default() + }; - RelayerRepoModel { - id: "test-relayer-id".to_string(), - name: "Test Relayer".to_string(), - network: "testnet".to_string(), - paused: false, - network_type: NetworkType::Stellar, - signer_id: "signer-id".to_string(), - policies: RelayerNetworkPolicy::Stellar(policy), - address: TEST_PK.to_string(), - notification_id: Some("notification-id".to_string()), - system_disabled: false, - custom_rpc_urls: None, + let result = calculate_total_soroban_fee(&sim_response, 3); + assert!(result.is_ok()); + // inclusion_fee (100 * 3) + resource_fee (50000) = 50300 + let fee = result.unwrap(); + assert_eq!(fee, 50300); + } + + #[test] + fn test_calculate_total_soroban_fee_simulation_error() { + let sim_response = soroban_rs::stellar_rpc_client::SimulateTransactionResponse { + error: Some("Simulation failed: insufficient funds".to_string()), + transaction_data: "".to_string(), + min_resource_fee: 0, ..Default::default() + }; + + let result = calculate_total_soroban_fee(&sim_response, 1); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, RelayerError::ValidationError(_))); + if let RelayerError::ValidationError(msg) = err { + assert!(msg.contains("Simulation failed")); } } - /// Helper function to create a mock DEX service - fn create_mock_dex_service() -> Arc { - let mut mock_dex = MockStellarDexServiceTrait::new(); - mock_dex - .expect_supported_asset_types() - .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic])); - Arc::new(mock_dex) + #[test] + fn test_calculate_total_soroban_fee_minimum_fee() { + // When calculated fee is less than minimum, should return minimum + let sim_response = soroban_rs::stellar_rpc_client::SimulateTransactionResponse { + error: None, + transaction_data: "".to_string(), + min_resource_fee: 0, // Very low resource fee + ..Default::default() + }; + + let result = calculate_total_soroban_fee(&sim_response, 1); + assert!(result.is_ok()); + // Should be at least STELLAR_DEFAULT_TRANSACTION_FEE (100) + let fee = result.unwrap(); + assert!(fee >= STELLAR_DEFAULT_TRANSACTION_FEE); } - /// Helper function to create a test network - fn create_test_network() -> NetworkRepoModel { - NetworkRepoModel { - id: "stellar:testnet".to_string(), - name: "testnet".to_string(), - network_type: NetworkType::Stellar, - config: NetworkConfigData::Stellar(StellarNetworkConfig { - common: NetworkConfigCommon { - network: "testnet".to_string(), - from: None, - rpc_urls: Some(vec![RpcConfig::new( - "https://horizon-testnet.stellar.org".to_string(), - )]), - explorer_urls: None, - average_blocktime_ms: Some(5000), - is_testnet: Some(true), - tags: None, - }, - passphrase: Some(TEST_NETWORK_PASSPHRASE.to_string()), - horizon_url: Some("https://horizon-testnet.stellar.org".to_string()), - }), + // ============================================================================ + // Tests for build_soroban_transaction_envelope + // ============================================================================ + + #[test] + fn test_build_soroban_transaction_envelope_success() { + use soroban_rs::xdr::{ + ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Operation, + OperationBody, ScAddress, ScSymbol, VecM, + }; + + let contract_id = ContractId(Hash([1u8; 32])); + let invoke_args = InvokeContractArgs { + contract_address: ScAddress::Contract(contract_id), + function_name: ScSymbol("test".try_into().unwrap()), + args: VecM::default(), + }; + + let invoke_op = InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(invoke_args), + auth: VecM::default(), + }; + + let operation = Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(invoke_op), + }; + + let result = build_soroban_transaction_envelope(TEST_PK, operation.clone(), 100); + assert!(result.is_ok()); + + let envelope = result.unwrap(); + if let TransactionEnvelope::Tx(tx_env) = envelope { + assert_eq!(tx_env.tx.fee, 100); + assert_eq!(tx_env.tx.seq_num.0, 0); // Placeholder sequence + assert_eq!(tx_env.tx.operations.len(), 1); + } else { + panic!("Expected Tx envelope"); } } - /// Helper function to create a Stellar relayer instance for testing - async fn create_test_relayer_instance( - relayer_model: RelayerRepoModel, - provider: MockStellarProviderTrait, - dex_service: Arc, - ) -> crate::domain::relayer::stellar::StellarRelayer< - MockStellarProviderTrait, - MockRelayerRepository, - InMemoryNetworkRepository, - MockTransactionRepository, - MockJobProducerTrait, - MockTransactionCounterServiceTrait, - MockStellarSignTrait, - MockStellarDexServiceTrait, - > { - let network_repository = Arc::new(InMemoryNetworkRepository::new()); - let test_network = create_test_network(); - network_repository.create(test_network).await.unwrap(); + #[test] + fn test_build_soroban_transaction_envelope_invalid_source() { + use soroban_rs::xdr::{ + ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Operation, + OperationBody, ScAddress, ScSymbol, VecM, + }; - let relayer_repo = Arc::new(MockRelayerRepository::new()); - let tx_repo = Arc::new(MockTransactionRepository::new()); - let job_producer = Arc::new(MockJobProducerTrait::new()); - let counter = Arc::new(MockTransactionCounterServiceTrait::new()); - let signer = Arc::new(MockStellarSignTrait::new()); + let contract_id = ContractId(Hash([1u8; 32])); + let invoke_args = InvokeContractArgs { + contract_address: ScAddress::Contract(contract_id), + function_name: ScSymbol("test".try_into().unwrap()), + args: VecM::default(), + }; - crate::domain::relayer::stellar::StellarRelayer::new( - relayer_model, - signer, - provider, - crate::domain::relayer::stellar::StellarRelayerDependencies::new( - relayer_repo, - network_repository, - tx_repo, - counter, - job_producer, - ), - dex_service, + let invoke_op = InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(invoke_args), + auth: VecM::default(), + }; + + let operation = Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(invoke_op), + }; + + let result = build_soroban_transaction_envelope("INVALID_ADDRESS", operation, 100); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + RelayerError::ValidationError(_) + )); + } + + // ============================================================================ + // Tests for add_payment_operation_to_envelope + // ============================================================================ + + #[test] + fn test_add_payment_operation_to_envelope_classic() { + let envelope = create_test_envelope_for_payment(); + let fee_quote = FeeQuote { + fee_in_token: 1000000, + fee_in_token_ui: "1.0".to_string(), + fee_in_stroops: 10000, + conversion_rate: 100.0, + }; + + let result = add_payment_operation_to_envelope(envelope, &fee_quote, USDC_ASSET, TEST_PK); + assert!(result.is_ok()); + + let updated_envelope = result.unwrap(); + // Classic transaction should have 2 operations now (original + payment) + if let TransactionEnvelope::Tx(tx_env) = updated_envelope { + assert_eq!(tx_env.tx.operations.len(), 2); + } + } + + #[test] + fn test_add_payment_operation_to_envelope_soroban_no_op_added() { + use soroban_rs::xdr::{ + ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo, + Operation, OperationBody, Preconditions, ScAddress, ScSymbol, SequenceNumber, + Transaction, TransactionEnvelope, TransactionExt, TransactionV1Envelope, Uint256, VecM, + }; + + // Create a Soroban transaction (InvokeHostFunction) + let source_pk = Ed25519PublicKey::from_string( + "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2", ) - .await - .unwrap() + .unwrap(); + + let contract_id = ContractId(Hash([1u8; 32])); + let invoke_args = InvokeContractArgs { + contract_address: ScAddress::Contract(contract_id), + function_name: ScSymbol("test".try_into().unwrap()), + args: VecM::default(), + }; + + let invoke_op = InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(invoke_args), + auth: VecM::default(), + }; + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)), + fee: 100, + seq_num: SequenceNumber(1), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(invoke_op), + }] + .try_into() + .unwrap(), + ext: TransactionExt::V0, + }; + + let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }); + + let fee_quote = FeeQuote { + fee_in_token: 1000000, + fee_in_token_ui: "1.0".to_string(), + fee_in_stroops: 10000, + conversion_rate: 100.0, + }; + + let result = add_payment_operation_to_envelope(envelope, &fee_quote, USDC_ASSET, TEST_PK); + assert!(result.is_ok()); + + // Soroban transactions should NOT have payment operation added + let updated_envelope = result.unwrap(); + if let TransactionEnvelope::Tx(tx_env) = updated_envelope { + assert_eq!(tx_env.tx.operations.len(), 1); // Still only 1 operation + } } - #[tokio::test] - async fn test_quote_sponsored_transaction_with_xdr() { - let relayer_model = create_test_relayer_with_user_fee_strategy(); - let mut provider = MockStellarProviderTrait::new(); + /// Helper to create a test envelope for payment tests + fn create_test_envelope_for_payment() -> TransactionEnvelope { + let source_pk = Ed25519PublicKey::from_string( + "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2", + ) + .unwrap(); + let dest_pk = Ed25519PublicKey::from_string( + "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ", + ) + .unwrap(); + + let payment_op = PaymentOp { + destination: MuxedAccount::Ed25519(Uint256(dest_pk.0)), + asset: soroban_rs::xdr::Asset::Native, + amount: 1000000, + }; + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)), + fee: 100, + seq_num: SequenceNumber(1), + cond: Preconditions::None, + memo: soroban_rs::xdr::Memo::None, + operations: vec![Operation { + source_account: None, + body: OperationBody::Payment(payment_op), + }] + .try_into() + .unwrap(), + ext: TransactionExt::V0, + }; + + TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }) + } + + // ============================================================================ + // Tests for add_fee_payment_operation + // ============================================================================ + + #[test] + fn test_add_fee_payment_operation_success() { + let mut envelope = create_test_envelope_for_payment(); + let result = add_fee_payment_operation(&mut envelope, USDC_ASSET, 1000000, TEST_PK); + assert!(result.is_ok()); + + // Verify operation was added + if let TransactionEnvelope::Tx(tx_env) = envelope { + assert_eq!(tx_env.tx.operations.len(), 2); + } + } - // Mock account for validation - provider.expect_get_account().returning(|_| { - Box::pin(ready(Ok(AccountEntry { - account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))), - balance: 1000000000, - seq_num: SequenceNumber(1), - num_sub_entries: 0, - inflation_dest: None, - flags: 0, - home_domain: String32::default(), - thresholds: Thresholds([0; 4]), - signers: VecM::default(), - ext: AccountEntryExt::V0, - }))) - }); + #[test] + fn test_add_fee_payment_operation_native_asset() { + let mut envelope = create_test_envelope_for_payment(); + let result = add_fee_payment_operation(&mut envelope, "native", 1000000, TEST_PK); + assert!(result.is_ok()); + } - // Mock get_ledger_entries for token balance validation - // This mock extracts the account ID from the ledger key and returns a trustline with sufficient balance - provider.expect_get_ledger_entries().returning(|keys| { - // Extract account ID from the first ledger key (should be a Trustline key) - let account_id = if let Some(LedgerKey::Trustline(trustline_key)) = keys.first() { - trustline_key.account_id.clone() - } else { - // Fallback: try to parse TEST_PK - parse_account_id(TEST_PK).unwrap_or_else(|_| { - AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))) - }) - }; + // ============================================================================ + // Tests for SorobanInvokeInfo + // ============================================================================ - let issuer_id = - parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN") - .unwrap_or_else(|_| { - AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))) - }); + #[test] + fn test_soroban_invoke_info_debug_clone() { + use soroban_rs::xdr::ScVal; - // Create a trustline entry with sufficient balance - let trustline_entry = TrustLineEntry { - account_id, - asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 { - asset_code: AssetCode4(*b"USDC"), - issuer: issuer_id, - }), - balance: 10_000_000i64, - limit: i64::MAX, - flags: 0, - ext: TrustLineEntryExt::V0, - }; + let info = SorobanInvokeInfo { + target_contract: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + target_fn: "transfer".to_string(), + target_args: vec![ScVal::Bool(true)], + }; - let ledger_entry = LedgerEntry { - last_modified_ledger_seq: 0, - data: LedgerEntryData::Trustline(trustline_entry), - ext: LedgerEntryExt::V0, - }; + // Test Debug trait + let debug_str = format!("{:?}", info); + assert!(debug_str.contains("SorobanInvokeInfo")); + assert!(debug_str.contains("transfer")); - // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry) - let xdr = ledger_entry - .data - .to_xdr_base64(soroban_rs::xdr::Limits::none()) - .expect("Failed to encode trustline entry data to XDR"); + // Test Clone trait + let cloned = info.clone(); + assert_eq!(cloned.target_contract, info.target_contract); + assert_eq!(cloned.target_fn, info.target_fn); + assert_eq!(cloned.target_args.len(), info.target_args.len()); + } - Box::pin(ready(Ok(GetLedgerEntriesResponse { - entries: Some(vec![LedgerEntryResult { - key: "test_key".to_string(), - xdr, - last_modified_ledger: 0u32, - live_until_ledger_seq_ledger_seq: None, - }]), - latest_ledger: 0, - }))) - }); + // ============================================================================ + // Tests for fee payment strategy validation + // ============================================================================ - let mut dex_service = MockStellarDexServiceTrait::new(); - dex_service - .expect_supported_asset_types() - .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic])); + #[tokio::test] + async fn test_build_sponsored_transaction_non_user_fee_strategy() { + // Create relayer with Relayer fee payment strategy (not User) + let mut policy = RelayerStellarPolicy::default(); + policy.fee_payment_strategy = Some(crate::models::StellarFeePaymentStrategy::Relayer); + policy.allowed_tokens = Some(vec![crate::models::StellarAllowedTokensPolicy { + asset: USDC_ASSET.to_string(), + metadata: None, + max_allowed_fee: None, + swap_config: None, + }]); - // Mock get_xlm_to_token_quote for fee conversion (XLM -> token) - dex_service - .expect_get_xlm_to_token_quote() - .returning(|_, _, _, _| { - Box::pin(ready(Ok( - crate::services::stellar_dex::StellarQuoteResponse { - input_asset: "native".to_string(), - output_asset: USDC_ASSET.to_string(), - in_amount: 100000, - out_amount: 1500000, - price_impact_pct: 0.0, - slippage_bps: 100, - path: None, - }, - ))) - }); + let relayer_model = RelayerRepoModel { + id: "test-relayer-id".to_string(), + name: "Test Relayer".to_string(), + network: "testnet".to_string(), + paused: false, + network_type: NetworkType::Stellar, + signer_id: "signer-id".to_string(), + policies: RelayerNetworkPolicy::Stellar(policy), + address: TEST_PK.to_string(), + notification_id: Some("notification-id".to_string()), + system_disabled: false, + custom_rpc_urls: None, + ..Default::default() + }; - let dex_service = Arc::new(dex_service); + let provider = MockStellarProviderTrait::new(); + let dex_service = create_mock_dex_service(); let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; let transaction_xdr = create_test_transaction_xdr(); - let request = SponsoredTransactionQuoteRequest::Stellar( - crate::models::StellarFeeEstimateRequestParams { + let request = SponsoredTransactionBuildRequest::Stellar( + crate::models::StellarPrepareTransactionRequestParams { transaction_xdr: Some(transaction_xdr), operations: None, source_account: None, @@ -740,114 +2770,180 @@ mod tests { }, ); - let result = relayer.quote_sponsored_transaction(request).await; - if let Err(e) = &result { - eprintln!("Quote error: {:?}", e); + let result = relayer.build_sponsored_transaction(request).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, RelayerError::ValidationError(_))); + if let RelayerError::ValidationError(msg) = err { + assert!(msg.contains("fee_payment_strategy: User")); } - assert!(result.is_ok()); + } - if let SponsoredTransactionQuoteResponse::Stellar(quote) = result.unwrap() { - assert_eq!(quote.fee_in_token, "1500000"); - assert!(!quote.fee_in_token_ui.is_empty()); - assert!(!quote.conversion_rate.is_empty()); - } else { - panic!("Expected Stellar quote response"); - } + // ============================================================================ + // Tests for quote_soroban_from_xdr (via quote_sponsored_transaction) + // ============================================================================ + + /// Helper function to create a valid SorobanTransactionData XDR for mocking simulation responses + fn create_valid_soroban_transaction_data_xdr() -> String { + use soroban_rs::xdr::{ + LedgerFootprint, SorobanResources, SorobanTransactionData, SorobanTransactionDataExt, + }; + + let soroban_data = SorobanTransactionData { + ext: SorobanTransactionDataExt::V0, + resources: SorobanResources { + footprint: LedgerFootprint { + read_only: VecM::default(), + read_write: VecM::default(), + }, + instructions: 1000000, + disk_read_bytes: 10000, + write_bytes: 1000, + }, + resource_fee: 50000, + }; + + soroban_data.to_xdr_base64(Limits::none()).unwrap() } - #[tokio::test] - async fn test_quote_sponsored_transaction_with_operations() { - let relayer_model = create_test_relayer_with_user_fee_strategy(); - let mut provider = MockStellarProviderTrait::new(); + /// Helper function to create a Soroban InvokeHostFunction transaction XDR + fn create_test_soroban_transaction_xdr() -> String { + use soroban_rs::xdr::{ + ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo, + ScAddress, ScSymbol, ScVal, + }; - provider.expect_get_account().returning(|_| { - Box::pin(ready(Ok(AccountEntry { - account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))), - balance: 1000000000, - seq_num: SequenceNumber(-1), - num_sub_entries: 0, - inflation_dest: None, - flags: 0, - home_domain: String32::default(), - thresholds: Thresholds([0; 4]), - signers: VecM::default(), - ext: AccountEntryExt::V0, - }))) + let source_pk = Ed25519PublicKey::from_string( + "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2", + ) + .unwrap(); + + // Create a Soroban contract call operation + let contract_id = ContractId(Hash([1u8; 32])); + let invoke_args = InvokeContractArgs { + contract_address: ScAddress::Contract(contract_id), + function_name: ScSymbol("transfer".try_into().unwrap()), + args: vec![ScVal::Bool(true)].try_into().unwrap(), + }; + + let invoke_op = InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(invoke_args), + auth: VecM::default(), + }; + + let operation = Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(invoke_op), + }; + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)), + fee: 100, + seq_num: SequenceNumber(1), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![operation].try_into().unwrap(), + ext: TransactionExt::V0, + }; + + let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), }); - // Mock get_ledger_entries for token balance validation - // This mock extracts the account ID from the ledger key and returns a trustline with sufficient balance - provider.expect_get_ledger_entries().returning(|keys| { - // Extract account ID from the first ledger key (should be a Trustline key) - let account_id = if let Some(LedgerKey::Trustline(trustline_key)) = keys.first() { - trustline_key.account_id.clone() - } else { - // Fallback: use the source account from the test - parse_account_id("GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2") - .unwrap_or_else(|_| { - AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))) - }) - }; + envelope.to_xdr_base64(Limits::none()).unwrap() + } - let issuer_id = - parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN") - .unwrap_or_else(|_| { - AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))) - }); + /// Helper function to create a relayer with Soroban token support + fn create_test_relayer_with_soroban_token() -> RelayerRepoModel { + let mut policy = RelayerStellarPolicy::default(); + policy.fee_payment_strategy = Some(crate::models::StellarFeePaymentStrategy::User); + // Use a Soroban contract address (C...) as the allowed token + policy.allowed_tokens = Some(vec![crate::models::StellarAllowedTokensPolicy { + asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + metadata: None, + max_allowed_fee: None, + swap_config: None, + }]); - // Create a trustline entry with sufficient balance - let trustline_entry = TrustLineEntry { - account_id, - asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 { - asset_code: AssetCode4(*b"USDC"), - issuer: issuer_id, - }), - balance: 10_000_000i64, - limit: i64::MAX, - flags: 0, - ext: TrustLineEntryExt::V0, - }; + RelayerRepoModel { + id: "test-relayer-id".to_string(), + name: "Test Relayer".to_string(), + network: "testnet".to_string(), + paused: false, + network_type: NetworkType::Stellar, + signer_id: "signer-id".to_string(), + policies: RelayerNetworkPolicy::Stellar(policy), + address: TEST_PK.to_string(), + notification_id: Some("notification-id".to_string()), + system_disabled: false, + custom_rpc_urls: None, + ..Default::default() + } + } - let ledger_entry = LedgerEntry { - last_modified_ledger_seq: 0, - data: LedgerEntryData::Trustline(trustline_entry), - ext: LedgerEntryExt::V0, - }; + #[tokio::test] + #[serial] + async fn test_quote_soroban_from_xdr_success() { + // Set required env var for FeeForwarder (testnet network) + std::env::set_var( + "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS", + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + ); - // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry) - let xdr = ledger_entry - .data - .to_xdr_base64(soroban_rs::xdr::Limits::none()) - .expect("Failed to encode trustline entry data to XDR"); + let relayer_model = create_test_relayer_with_soroban_token(); + let mut provider = MockStellarProviderTrait::new(); + // Mock get_latest_ledger for expiration calculation + provider.expect_get_latest_ledger().returning(|| { Box::pin(ready(Ok( - soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse { - entries: Some(vec![LedgerEntryResult { - key: "test_key".to_string(), - xdr, - last_modified_ledger: 0u32, - live_until_ledger_seq_ledger_seq: None, - }]), - latest_ledger: 0, + soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { + id: "test".to_string(), + protocol_version: 20, + sequence: 1000, }, ))) }); + // Mock simulate_transaction_envelope for Soroban fee estimation + provider + .expect_simulate_transaction_envelope() + .returning(|_| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::SimulateTransactionResponse { + min_resource_fee: 50000, + transaction_data: "AAAAAQAAAAAAAAACAAAAAAAAAAAAAAAAAAAABgAAAAEAAAAGAAAAAG0JZTO9fU6p3NeJp5w3TpKhZmx6p1pR7mq9wFwCnEIuAAAAFAAAAAEAAAAAAAAAB8NVb2IAAAH0AAAAAQAAAAAAABfAAAAAAAAAAPUAAAAAAAAENgAAAAA=".to_string(), + ..Default::default() + }, + ))) + }); + + // Mock call_contract for Soroban token balance check (balance function) + provider.expect_call_contract().returning(|_, _, _| { + use soroban_rs::xdr::Int128Parts; + // Return a balance of 10_000_000 (10 tokens with 6 decimals) + Box::pin(ready(Ok(ScVal::I128(Int128Parts { + hi: 0, + lo: 10_000_000, + })))) + }); + let mut dex_service = MockStellarDexServiceTrait::new(); - dex_service - .expect_supported_asset_types() - .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic])); + dex_service.expect_supported_asset_types().returning(|| { + std::collections::HashSet::from([AssetType::Native, AssetType::Contract]) + }); - // Mock get_xlm_to_token_quote for fee conversion (XLM -> token) + // Mock get_xlm_to_token_quote for fee conversion dex_service .expect_get_xlm_to_token_quote() .returning(|_, _, _, _| { Box::pin(ready(Ok( crate::services::stellar_dex::StellarQuoteResponse { input_asset: "native".to_string(), - output_asset: USDC_ASSET.to_string(), - in_amount: 100000, - out_amount: 1500000, + output_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" + .to_string(), + in_amount: 50100, // fee in stroops + out_amount: 1500000, // fee in token price_impact_pct: 0.0, slippage_bps: 100, path: None, @@ -858,171 +2954,372 @@ mod tests { let dex_service = Arc::new(dex_service); let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; - let operations = vec![OperationSpec::Payment { - destination: TEST_PK.to_string(), - amount: 1000000, - asset: AssetSpec::Native, - }]; - + let transaction_xdr = create_test_soroban_transaction_xdr(); let request = SponsoredTransactionQuoteRequest::Stellar( crate::models::StellarFeeEstimateRequestParams { - transaction_xdr: None, - operations: Some(operations), - source_account: Some( - "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2".to_string(), - ), - fee_token: USDC_ASSET.to_string(), + transaction_xdr: Some(transaction_xdr), + operations: None, + source_account: None, + fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), }, ); let result = relayer.quote_sponsored_transaction(request).await; if let Err(e) = &result { - eprintln!("Quote error: {:?}", e); + eprintln!("Soroban quote error: {:?}", e); } assert!(result.is_ok()); + + if let SponsoredTransactionQuoteResponse::Stellar(quote) = result.unwrap() { + assert_eq!(quote.fee_in_token, "1500000"); + assert!(!quote.fee_in_token_ui.is_empty()); + assert!(!quote.conversion_rate.is_empty()); + } else { + panic!("Expected Stellar quote response"); + } + + // Clean up env var + std::env::remove_var("STELLAR_TESTNET_FEE_FORWARDER_ADDRESS"); } #[tokio::test] - async fn test_quote_sponsored_transaction_invalid_token() { - let relayer_model = create_test_relayer_with_user_fee_strategy(); + #[serial] + async fn test_quote_soroban_from_xdr_missing_fee_forwarder() { + // Ensure env var is NOT set + std::env::remove_var("STELLAR_MAINNET_FEE_FORWARDER_ADDRESS"); + + // Use mainnet network where FeeForwarder is not deployed (empty default address) + let mut relayer_model = create_test_relayer_with_soroban_token(); + relayer_model.network = "mainnet".to_string(); + let provider = MockStellarProviderTrait::new(); - let dex_service = create_mock_dex_service(); - let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; - let transaction_xdr = create_test_transaction_xdr(); + let mut dex_service = MockStellarDexServiceTrait::new(); + dex_service.expect_supported_asset_types().returning(|| { + std::collections::HashSet::from([AssetType::Native, AssetType::Contract]) + }); + + let dex_service = Arc::new(dex_service); + let relayer = create_test_relayer_instance_with_network( + relayer_model, + provider, + dex_service, + create_test_mainnet_network(), + ) + .await; + + let transaction_xdr = create_test_soroban_transaction_xdr(); let request = SponsoredTransactionQuoteRequest::Stellar( crate::models::StellarFeeEstimateRequestParams { transaction_xdr: Some(transaction_xdr), operations: None, source_account: None, - fee_token: "INVALID:TOKEN".to_string(), + fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), }, ); let result = relayer.quote_sponsored_transaction(request).await; assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - RelayerError::ValidationError(_) - )); + let err = result.unwrap_err(); + assert!(matches!(err, RelayerError::ValidationError(_))); + if let RelayerError::ValidationError(msg) = err { + assert!(msg.contains("STELLAR_MAINNET_FEE_FORWARDER_ADDRESS")); + } } #[tokio::test] - async fn test_quote_sponsored_transaction_missing_xdr_and_operations() { - let relayer_model = create_test_relayer_with_user_fee_strategy(); + #[serial] + async fn test_quote_soroban_from_xdr_invalid_fee_token_format() { + // Set required env var for FeeForwarder (testnet network) + std::env::set_var( + "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS", + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + ); + + // Create relayer that allows both classic and Soroban tokens + let mut policy = RelayerStellarPolicy::default(); + policy.fee_payment_strategy = Some(crate::models::StellarFeePaymentStrategy::User); + policy.allowed_tokens = Some(vec![ + crate::models::StellarAllowedTokensPolicy { + asset: USDC_ASSET.to_string(), // Classic asset + metadata: None, + max_allowed_fee: None, + swap_config: None, + }, + crate::models::StellarAllowedTokensPolicy { + asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + metadata: None, + max_allowed_fee: None, + swap_config: None, + }, + ]); + + let relayer_model = RelayerRepoModel { + id: "test-relayer-id".to_string(), + name: "Test Relayer".to_string(), + network: "testnet".to_string(), + paused: false, + network_type: NetworkType::Stellar, + signer_id: "signer-id".to_string(), + policies: RelayerNetworkPolicy::Stellar(policy), + address: TEST_PK.to_string(), + notification_id: Some("notification-id".to_string()), + system_disabled: false, + custom_rpc_urls: None, + ..Default::default() + }; + let provider = MockStellarProviderTrait::new(); - let dex_service = create_mock_dex_service(); + + let mut dex_service = MockStellarDexServiceTrait::new(); + dex_service + .expect_supported_asset_types() + .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic])); + + let dex_service = Arc::new(dex_service); let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; + // Use Soroban XDR but with classic asset as fee_token (invalid for Soroban path) + let transaction_xdr = create_test_soroban_transaction_xdr(); let request = SponsoredTransactionQuoteRequest::Stellar( crate::models::StellarFeeEstimateRequestParams { - transaction_xdr: None, + transaction_xdr: Some(transaction_xdr), operations: None, source_account: None, - fee_token: USDC_ASSET.to_string(), + fee_token: USDC_ASSET.to_string(), // Classic asset, not valid C... format }, ); let result = relayer.quote_sponsored_transaction(request).await; assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - RelayerError::ValidationError(_) - )); + let err = result.unwrap_err(); + assert!(matches!(err, RelayerError::ValidationError(_))); + if let RelayerError::ValidationError(msg) = err { + assert!(msg.contains("Soroban contract address")); + } + + // Clean up env var + std::env::remove_var("STELLAR_TESTNET_FEE_FORWARDER_ADDRESS"); } + // ============================================================================ + // Tests for build_soroban_sponsored (via build_sponsored_transaction) + // ============================================================================ + #[tokio::test] - async fn test_build_sponsored_transaction_with_xdr() { - let relayer_model = create_test_relayer_with_user_fee_strategy(); + #[serial] + async fn test_build_soroban_sponsored_success() { + // Set required env var for FeeForwarder (testnet network) + std::env::set_var( + "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS", + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + ); + + let relayer_model = create_test_relayer_with_soroban_token(); let mut provider = MockStellarProviderTrait::new(); - provider.expect_get_account().returning(|_| { - Box::pin(ready(Ok(AccountEntry { - account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))), - balance: 1000000000, - seq_num: SequenceNumber(-1), - num_sub_entries: 0, - inflation_dest: None, - flags: 0, - home_domain: String32::default(), - thresholds: Thresholds([0; 4]), - signers: VecM::default(), - ext: AccountEntryExt::V0, - }))) + // Mock get_latest_ledger for expiration calculation (called twice - for simulation and for valid_until) + provider.expect_get_latest_ledger().returning(|| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { + id: "test".to_string(), + protocol_version: 20, + sequence: 1000, + }, + ))) }); - // Mock get_ledger_entries for token balance validation - // This mock extracts the account ID from the ledger key and returns a trustline with sufficient balance - provider.expect_get_ledger_entries().returning(|keys| { - // Extract account ID from the first ledger key (should be a Trustline key) - let account_id = if let Some(LedgerKey::Trustline(trustline_key)) = keys.first() { - trustline_key.account_id.clone() - } else { - // Fallback: try to parse TEST_PK - parse_account_id(TEST_PK).unwrap_or_else(|_| { - AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))) - }) - }; + // Mock simulate_transaction_envelope for Soroban fee estimation + let valid_tx_data = create_valid_soroban_transaction_data_xdr(); + provider + .expect_simulate_transaction_envelope() + .returning(move |_| { + let tx_data = valid_tx_data.clone(); + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::SimulateTransactionResponse { + min_resource_fee: 50000, + transaction_data: tx_data, + ..Default::default() + }, + ))) + }); - let issuer_id = - parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN") - .unwrap_or_else(|_| { - AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))) - }); + // Mock call_contract for Soroban token balance check + provider.expect_call_contract().returning(|_, _, _| { + use soroban_rs::xdr::Int128Parts; + // Return a balance of 10_000_000 (sufficient for fee) + Box::pin(ready(Ok(ScVal::I128(Int128Parts { + hi: 0, + lo: 10_000_000, + })))) + }); - // Create a trustline entry with sufficient balance (10 USDC = 10000000 with 6 decimals) - let trustline_entry = TrustLineEntry { - account_id, - asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 { - asset_code: AssetCode4(*b"USDC"), - issuer: issuer_id, - }), - balance: 10_000_000i64, // 10 USDC (with 6 decimals) - sufficient for fee - limit: i64::MAX, - flags: 0, - ext: TrustLineEntryExt::V0, // V0 has no liabilities - }; + let mut dex_service = MockStellarDexServiceTrait::new(); + dex_service.expect_supported_asset_types().returning(|| { + std::collections::HashSet::from([AssetType::Native, AssetType::Contract]) + }); - let ledger_entry = LedgerEntry { - last_modified_ledger_seq: 0, - data: LedgerEntryData::Trustline(trustline_entry), - ext: LedgerEntryExt::V0, - }; + // Mock get_xlm_to_token_quote for fee conversion + dex_service + .expect_get_xlm_to_token_quote() + .returning(|_, _, _, _| { + Box::pin(ready(Ok( + crate::services::stellar_dex::StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" + .to_string(), + in_amount: 50100, + out_amount: 1500000, + price_impact_pct: 0.0, + slippage_bps: 100, + path: None, + }, + ))) + }); - // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry) - let xdr = ledger_entry - .data - .to_xdr_base64(soroban_rs::xdr::Limits::none()) - .expect("Failed to encode trustline entry data to XDR"); + let dex_service = Arc::new(dex_service); + let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; + + let transaction_xdr = create_test_soroban_transaction_xdr(); + let request = SponsoredTransactionBuildRequest::Stellar( + crate::models::StellarPrepareTransactionRequestParams { + transaction_xdr: Some(transaction_xdr), + operations: None, + source_account: None, + fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + }, + ); + + let result = relayer.build_sponsored_transaction(request).await; + if let Err(e) = &result { + eprintln!("Soroban build error: {:?}", e); + } + assert!(result.is_ok()); + + if let SponsoredTransactionBuildResponse::Stellar(build) = result.unwrap() { + assert!(!build.transaction.is_empty()); + assert_eq!(build.fee_in_token, "1500000"); + assert!(!build.fee_in_token_ui.is_empty()); + assert_eq!( + build.fee_token, + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" + ); + assert!(!build.valid_until.is_empty()); + // Soroban transactions should have user_auth_entry + assert!(build.user_auth_entry.is_some()); + assert!(!build.user_auth_entry.unwrap().is_empty()); + } else { + panic!("Expected Stellar build response"); + } + + // Clean up env var + std::env::remove_var("STELLAR_TESTNET_FEE_FORWARDER_ADDRESS"); + } + + #[tokio::test] + #[serial] + async fn test_build_soroban_sponsored_missing_fee_forwarder() { + // Ensure env var is NOT set + std::env::remove_var("STELLAR_MAINNET_FEE_FORWARDER_ADDRESS"); + + // Use mainnet network where FeeForwarder is not deployed (empty default address) + let mut relayer_model = create_test_relayer_with_soroban_token(); + relayer_model.network = "mainnet".to_string(); + + let provider = MockStellarProviderTrait::new(); + + let mut dex_service = MockStellarDexServiceTrait::new(); + dex_service.expect_supported_asset_types().returning(|| { + std::collections::HashSet::from([AssetType::Native, AssetType::Contract]) + }); + + let dex_service = Arc::new(dex_service); + let relayer = create_test_relayer_instance_with_network( + relayer_model, + provider, + dex_service, + create_test_mainnet_network(), + ) + .await; + + let transaction_xdr = create_test_soroban_transaction_xdr(); + let request = SponsoredTransactionBuildRequest::Stellar( + crate::models::StellarPrepareTransactionRequestParams { + transaction_xdr: Some(transaction_xdr), + operations: None, + source_account: None, + fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + }, + ); + + let result = relayer.build_sponsored_transaction(request).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, RelayerError::ValidationError(_))); + if let RelayerError::ValidationError(msg) = err { + assert!(msg.contains("STELLAR_MAINNET_FEE_FORWARDER_ADDRESS")); + } + } + + #[tokio::test] + #[serial] + async fn test_build_soroban_sponsored_insufficient_balance() { + // Set required env var for FeeForwarder (testnet network) + std::env::set_var( + "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS", + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + ); + let relayer_model = create_test_relayer_with_soroban_token(); + let mut provider = MockStellarProviderTrait::new(); + + // Mock get_latest_ledger + provider.expect_get_latest_ledger().returning(|| { Box::pin(ready(Ok( - soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse { - entries: Some(vec![LedgerEntryResult { - key: "test_key".to_string(), - xdr, - last_modified_ledger: 0u32, - live_until_ledger_seq_ledger_seq: None, - }]), - latest_ledger: 0, + soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { + id: "test".to_string(), + protocol_version: 20, + sequence: 1000, }, ))) }); + // Mock simulate_transaction_envelope + provider + .expect_simulate_transaction_envelope() + .returning(|_| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::SimulateTransactionResponse { + min_resource_fee: 50000, + transaction_data: "AAAAAQAAAAAAAAACAAAAAAAAAAAAAAAAAAAABgAAAAEAAAAGAAAAAG0JZTO9fU6p3NeJp5w3TpKhZmx6p1pR7mq9wFwCnEIuAAAAFAAAAAEAAAAAAAAAB8NVb2IAAAH0AAAAAQAAAAAAABfAAAAAAAAAAPUAAAAAAAAENgAAAAA=".to_string(), + ..Default::default() + }, + ))) + }); + + // Mock call_contract with INSUFFICIENT balance + provider.expect_call_contract().returning(|_, _, _| { + use soroban_rs::xdr::Int128Parts; + // Return a very low balance (100, much less than required 1500000) + Box::pin(ready(Ok(ScVal::I128(Int128Parts { hi: 0, lo: 100 })))) + }); + let mut dex_service = MockStellarDexServiceTrait::new(); - dex_service - .expect_supported_asset_types() - .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic])); + dex_service.expect_supported_asset_types().returning(|| { + std::collections::HashSet::from([AssetType::Native, AssetType::Contract]) + }); - // Mock get_xlm_to_token_quote for build (converting XLM fee to token) + // Mock get_xlm_to_token_quote dex_service .expect_get_xlm_to_token_quote() .returning(|_, _, _, _| { Box::pin(ready(Ok( crate::services::stellar_dex::StellarQuoteResponse { input_asset: "native".to_string(), - output_asset: USDC_ASSET.to_string(), - in_amount: 1000000, - out_amount: 1500000, + output_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" + .to_string(), + in_amount: 50100, + out_amount: 1500000, // Fee required price_impact_pct: 0.0, slippage_bps: 100, path: None, @@ -1033,124 +3330,83 @@ mod tests { let dex_service = Arc::new(dex_service); let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; - let transaction_xdr = create_test_transaction_xdr(); + let transaction_xdr = create_test_soroban_transaction_xdr(); let request = SponsoredTransactionBuildRequest::Stellar( crate::models::StellarPrepareTransactionRequestParams { transaction_xdr: Some(transaction_xdr), operations: None, source_account: None, - fee_token: USDC_ASSET.to_string(), + fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), }, ); let result = relayer.build_sponsored_transaction(request).await; - assert!(result.is_ok()); - - if let SponsoredTransactionBuildResponse::Stellar(build) = result.unwrap() { - assert!(!build.transaction.is_empty()); - assert_eq!(build.fee_in_token, "1500000"); - assert!(!build.fee_in_token_ui.is_empty()); - assert_eq!(build.fee_token, USDC_ASSET); - assert!(!build.valid_until.is_empty()); - } else { - panic!("Expected Stellar build response"); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, RelayerError::ValidationError(_))); + if let RelayerError::ValidationError(msg) = err { + assert!(msg.contains("Insufficient balance")); } + + // Clean up env var + std::env::remove_var("STELLAR_TESTNET_FEE_FORWARDER_ADDRESS"); } #[tokio::test] - async fn test_build_sponsored_transaction_with_operations() { - let relayer_model = create_test_relayer_with_user_fee_strategy(); - let mut provider = MockStellarProviderTrait::new(); - - provider.expect_get_account().returning(|_| { - Box::pin(ready(Ok(AccountEntry { - account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))), - balance: 1000000000, - seq_num: SequenceNumber(-1), - num_sub_entries: 0, - inflation_dest: None, - flags: 0, - home_domain: String32::default(), - thresholds: Thresholds([0; 4]), - signers: VecM::default(), - ext: AccountEntryExt::V0, - }))) - }); - - provider.expect_get_ledger_entries().returning(|_| { - use crate::domain::transaction::stellar::utils::parse_account_id; - use soroban_rs::stellar_rpc_client::LedgerEntryResult; - use soroban_rs::xdr::{ - AccountId, AlphaNum4, AssetCode4, LedgerEntry, LedgerEntryData, LedgerEntryExt, - PublicKey, TrustLineEntry, TrustLineEntryExt, Uint256, WriteXdr, - }; - - // Parse account IDs - use the source account from the test - let account_id = - parse_account_id("GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2") - .unwrap_or_else(|_| { - AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))) - }); - let issuer_id = - parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN") - .unwrap_or_else(|_| { - AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))) - }); - - // Create a trustline entry with sufficient balance (10 USDC = 10000000 with 6 decimals) - // The fee is 1500000 (from the quote), so 10 USDC is more than enough - let trustline_entry = TrustLineEntry { - account_id, - asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 { - asset_code: AssetCode4(*b"USDC"), - issuer: issuer_id, - }), - balance: 10_000_000i64, - limit: i64::MAX, - flags: 0, - ext: TrustLineEntryExt::V0, - }; - - let ledger_entry = LedgerEntry { - last_modified_ledger_seq: 0, - data: LedgerEntryData::Trustline(trustline_entry), - ext: LedgerEntryExt::V0, - }; + #[serial] + async fn test_build_soroban_sponsored_simulation_error() { + // Set required env var for FeeForwarder (testnet network) + std::env::set_var( + "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS", + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + ); - // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry) - // The parse_ledger_entry_from_xdr function expects just the data portion - let xdr = ledger_entry - .data - .to_xdr_base64(soroban_rs::xdr::Limits::none()) - .expect("Failed to encode trustline entry data to XDR"); + let relayer_model = create_test_relayer_with_soroban_token(); + let mut provider = MockStellarProviderTrait::new(); + // Mock get_latest_ledger + provider.expect_get_latest_ledger().returning(|| { Box::pin(ready(Ok( - soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse { - entries: Some(vec![LedgerEntryResult { - key: "test_key".to_string(), - xdr, - last_modified_ledger: 0u32, - live_until_ledger_seq_ledger_seq: None, - }]), - latest_ledger: 0, + soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { + id: "test".to_string(), + protocol_version: 20, + sequence: 1000, }, ))) }); + // Mock simulate_transaction_envelope to return error + provider + .expect_simulate_transaction_envelope() + .returning(|_| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::SimulateTransactionResponse { + error: Some( + "Contract execution failed: insufficient resources".to_string(), + ), + min_resource_fee: 0, + transaction_data: "".to_string(), + ..Default::default() + }, + ))) + }); + let mut dex_service = MockStellarDexServiceTrait::new(); - dex_service - .expect_supported_asset_types() - .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic])); + dex_service.expect_supported_asset_types().returning(|| { + std::collections::HashSet::from([AssetType::Native, AssetType::Contract]) + }); + // Mock get_xlm_to_token_quote for initial fee estimation dex_service .expect_get_xlm_to_token_quote() .returning(|_, _, _, _| { Box::pin(ready(Ok( crate::services::stellar_dex::StellarQuoteResponse { input_asset: "native".to_string(), - output_asset: USDC_ASSET.to_string(), - in_amount: 1000000, - out_amount: 1500000, + output_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" + .to_string(), + in_amount: 100, + out_amount: 1500, price_impact_pct: 0.0, slippage_bps: 100, path: None, @@ -1161,164 +3417,26 @@ mod tests { let dex_service = Arc::new(dex_service); let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; - let operations = vec![OperationSpec::Payment { - destination: TEST_PK.to_string(), - amount: 1000000, - asset: AssetSpec::Native, - }]; - - let request = SponsoredTransactionBuildRequest::Stellar( - crate::models::StellarPrepareTransactionRequestParams { - transaction_xdr: None, - operations: Some(operations), - source_account: Some( - "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2".to_string(), - ), - fee_token: USDC_ASSET.to_string(), - }, - ); - - let result = relayer.build_sponsored_transaction(request).await; - - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_build_sponsored_transaction_missing_source_account() { - let relayer_model = create_test_relayer_with_user_fee_strategy(); - let provider = MockStellarProviderTrait::new(); - let dex_service = create_mock_dex_service(); - let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; - - let operations = vec![OperationSpec::Payment { - destination: TEST_PK.to_string(), - amount: 1000000, - asset: AssetSpec::Native, - }]; - + let transaction_xdr = create_test_soroban_transaction_xdr(); let request = SponsoredTransactionBuildRequest::Stellar( crate::models::StellarPrepareTransactionRequestParams { - transaction_xdr: None, - operations: Some(operations), + transaction_xdr: Some(transaction_xdr), + operations: None, source_account: None, - fee_token: USDC_ASSET.to_string(), + fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), }, ); let result = relayer.build_sponsored_transaction(request).await; assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - RelayerError::ValidationError(_) - )); - } - - #[tokio::test] - async fn test_build_envelope_from_request_with_xdr() { - let provider = MockStellarProviderTrait::new(); - let transaction_xdr = create_test_transaction_xdr(); - let result = build_envelope_from_request( - Some(&transaction_xdr), - None, - None, - TEST_NETWORK_PASSPHRASE, - &provider, - ) - .await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_build_envelope_from_request_with_operations() { - let mut provider = MockStellarProviderTrait::new(); - - // Mock get_account to return a valid account with sequence number - provider.expect_get_account().returning(|_| { - Box::pin(ready(Ok(AccountEntry { - account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))), - balance: 1000000000, - seq_num: SequenceNumber(100), - num_sub_entries: 0, - inflation_dest: None, - flags: 0, - home_domain: String32::default(), - thresholds: Thresholds([0; 4]), - signers: VecM::default(), - ext: AccountEntryExt::V0, - }))) - }); - - let operations = vec![OperationSpec::Payment { - destination: TEST_PK.to_string(), - amount: 1000000, - asset: AssetSpec::Native, - }]; - - let result = build_envelope_from_request( - None, - Some(&operations), - Some(&TEST_PK.to_string()), - TEST_NETWORK_PASSPHRASE, - &provider, - ) - .await; - assert!(result.is_ok()); - - // Verify the sequence number is set correctly (current + 1 = 101) - if let Ok(envelope) = result { - if let TransactionEnvelope::Tx(tx_env) = envelope { - assert_eq!(tx_env.tx.seq_num.0, 101); - } + let err = result.unwrap_err(); + // Simulation errors are wrapped in ValidationError via calculate_total_soroban_fee + assert!(matches!(err, RelayerError::ValidationError(_))); + if let RelayerError::ValidationError(msg) = err { + assert!(msg.contains("Simulation failed")); } - } - - #[tokio::test] - async fn test_build_envelope_from_request_missing_source_account() { - let provider = MockStellarProviderTrait::new(); - let operations = vec![OperationSpec::Payment { - destination: TEST_PK.to_string(), - amount: 1000000, - asset: AssetSpec::Native, - }]; - - let result = build_envelope_from_request( - None, - Some(&operations), - None, - TEST_NETWORK_PASSPHRASE, - &provider, - ) - .await; - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - RelayerError::ValidationError(_) - )); - } - - #[tokio::test] - async fn test_build_envelope_from_request_missing_both() { - let provider = MockStellarProviderTrait::new(); - let result = - build_envelope_from_request(None, None, None, TEST_NETWORK_PASSPHRASE, &provider).await; - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - RelayerError::ValidationError(_) - )); - } - #[tokio::test] - async fn test_build_envelope_from_request_invalid_xdr() { - let provider = MockStellarProviderTrait::new(); - let result = build_envelope_from_request( - Some(&"INVALID_XDR".to_string()), - None, - None, - TEST_NETWORK_PASSPHRASE, - &provider, - ) - .await; - assert!(result.is_err()); + // Clean up env var + std::env::remove_var("STELLAR_TESTNET_FEE_FORWARDER_ADDRESS"); } } diff --git a/src/domain/relayer/stellar/mod.rs b/src/domain/relayer/stellar/mod.rs index 697c730b0..851279fc9 100644 --- a/src/domain/relayer/stellar/mod.rs +++ b/src/domain/relayer/stellar/mod.rs @@ -4,6 +4,7 @@ pub use stellar_relayer::*; mod gas_abstraction; mod token_swap; +pub mod utils; pub mod xdr_utils; pub use xdr_utils::*; @@ -25,7 +26,7 @@ use crate::{ services::{ provider::get_network_provider, signer::StellarSignerFactory, - stellar_dex::{DexServiceWrapper, OrderBookService, StellarDexService}, + stellar_dex::{DexServiceWrapper, OrderBookService, SoroswapService, StellarDexService}, TransactionCounterService, }, }; @@ -114,9 +115,48 @@ pub async fn create_stellar_relayer< dex_services.push(DexServiceWrapper::OrderBook(order_book_service)); } StellarSwapStrategy::Soroswap => { - // TODO: Implement Soroswap service when available - // For now, skip if not available - tracing::warn!("Soroswap strategy is not yet implemented, skipping"); + let is_testnet = network.is_testnet(); + let network_label = if is_testnet { "TESTNET" } else { "MAINNET" }; + + let router_address = + crate::config::ServerConfig::resolve_stellar_soroswap_router_address( + is_testnet, + ) + .ok_or_else(|| { + RelayerError::NetworkConfiguration(format!( + "Soroswap router address not configured. Set STELLAR_{network_label}_SOROSWAP_ROUTER_ADDRESS env var." + )) + })?; + + let factory_address = + crate::config::ServerConfig::resolve_stellar_soroswap_factory_address( + is_testnet, + ) + .ok_or_else(|| { + RelayerError::NetworkConfiguration(format!( + "Soroswap factory address not configured. Set STELLAR_{network_label}_SOROSWAP_FACTORY_ADDRESS env var." + )) + })?; + + let native_wrapper_address = + crate::config::ServerConfig::resolve_stellar_soroswap_native_wrapper_address( + is_testnet, + ) + .ok_or_else(|| { + RelayerError::NetworkConfiguration(format!( + "Soroswap native wrapper address not configured. Set STELLAR_{network_label}_SOROSWAP_NATIVE_WRAPPER_ADDRESS env var." + )) + })?; + + let soroswap_service = Arc::new(SoroswapService::new( + router_address, + factory_address, + native_wrapper_address, + provider_arc.clone(), + network.passphrase.clone(), + )); + dex_services.push(DexServiceWrapper::Soroswap(soroswap_service)); + tracing::info!("Soroswap DEX service initialized"); } } } diff --git a/src/domain/relayer/stellar/stellar_relayer.rs b/src/domain/relayer/stellar/stellar_relayer.rs index 37b1c0a72..cbb7cd5c3 100644 --- a/src/domain/relayer/stellar/stellar_relayer.rs +++ b/src/domain/relayer/stellar/stellar_relayer.rs @@ -131,7 +131,7 @@ where D: StellarDexServiceTrait + Send + Sync + 'static, { pub(crate) relayer: RelayerRepoModel, - signer: Arc, + pub(crate) signer: Arc, pub(crate) network: StellarNetwork, pub(crate) provider: P, pub(crate) relayer_repository: Arc, @@ -2004,6 +2004,7 @@ mod tests { transaction_xdr: Some("AAAAAgAAAACige4lTdwSB/sto4SniEdJ2kOa2X65s5bqkd40J4DjSwAAAAEAAHAkAAAADwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAKKB7iVN3BIH+y2jhKeIR0naQ5rZfrmzluqR3jQngONLAAAAAAAAAAAAD0JAAAAAAAAAAAA=".to_string()), fee_bump: None, max_fee: None, + signed_auth_entry: None, }) } diff --git a/src/domain/relayer/stellar/token_swap.rs b/src/domain/relayer/stellar/token_swap.rs index 3dcc6e242..fdad4b310 100644 --- a/src/domain/relayer/stellar/token_swap.rs +++ b/src/domain/relayer/stellar/token_swap.rs @@ -237,6 +237,7 @@ where transaction_xdr: Some(xdr), fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let network_request = NetworkTransactionRequest::Stellar(stellar_request); diff --git a/src/domain/relayer/stellar/utils.rs b/src/domain/relayer/stellar/utils.rs new file mode 100644 index 000000000..1387bed8b --- /dev/null +++ b/src/domain/relayer/stellar/utils.rs @@ -0,0 +1,295 @@ +//! Stellar relayer utility functions. +//! +//! Generic helpers for ledger math, fee slippage, and other reusable logic +//! shared across gas abstraction and related code. + +use crate::constants::STELLAR_LEDGER_TIME_SECONDS; +use crate::models::RelayerError; +use crate::services::provider::StellarProviderTrait; + +/// Default slippage tolerance for max_fee_amount in basis points (500 = 5%). +/// Allows fee fluctuation between quote and execution time. +pub const DEFAULT_SOROBAN_MAX_FEE_SLIPPAGE_BPS: u64 = 500; + +/// Apply slippage tolerance to max_fee_amount for FeeForwarder. +/// +/// The FeeForwarder contract has separate `fee_amount` (what relayer charges at execution) +/// and `max_fee_amount` (user's authorized ceiling). Setting them equal means no room for +/// fee fluctuation between quote and execution. This function applies a slippage buffer +/// to allow for price movement. +/// +/// # Arguments +/// * `fee_in_token` - The calculated fee amount in token units +/// * `slippage_bps` - Slippage in basis points (default: [`DEFAULT_SOROBAN_MAX_FEE_SLIPPAGE_BPS`]) +/// +/// # Returns +/// The max_fee_amount with slippage buffer applied as i128 +pub fn apply_max_fee_slippage_with_bps(fee_in_token: u64, slippage_bps: u64) -> i128 { + let fee_with_slippage = (fee_in_token as u128) * (10000 + slippage_bps as u128) / 10000; + fee_with_slippage as i128 +} + +/// Apply default slippage to max_fee_amount (uses [`DEFAULT_SOROBAN_MAX_FEE_SLIPPAGE_BPS`]). +pub fn apply_max_fee_slippage(fee_in_token: u64) -> i128 { + apply_max_fee_slippage_with_bps(fee_in_token, DEFAULT_SOROBAN_MAX_FEE_SLIPPAGE_BPS) +} + +/// Calculate the expiration ledger for authorization. +/// +/// Uses the provider to get the current ledger sequence and adds the +/// specified validity duration (in seconds) converted to ledger count. +pub async fn get_expiration_ledger

( + provider: &P, + validity_seconds: u64, +) -> Result +where + P: StellarProviderTrait + Send + Sync, +{ + let current_ledger = provider + .get_latest_ledger() + .await + .map_err(|e| RelayerError::Internal(format!("Failed to get latest ledger: {e}")))?; + + let mut ledgers_to_add = validity_seconds.div_ceil(STELLAR_LEDGER_TIME_SECONDS); + if ledgers_to_add == 0 { + ledgers_to_add = 1; + } + Ok(current_ledger + .sequence + .saturating_add(ledgers_to_add as u32)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::future::ready; + + use crate::services::provider::MockStellarProviderTrait; + + // ============================================================================ + // Tests for apply_max_fee_slippage + // ============================================================================ + + #[test] + fn test_apply_max_fee_slippage_basic() { + // 5% slippage on 10000 should give 10500 + let result = apply_max_fee_slippage(10000); + assert_eq!(result, 10500); + } + + #[test] + fn test_apply_max_fee_slippage_zero() { + let result = apply_max_fee_slippage(0); + assert_eq!(result, 0); + } + + #[test] + fn test_apply_max_fee_slippage_large_value() { + let large_fee: u64 = 1_000_000_000_000; + let result = apply_max_fee_slippage(large_fee); + assert_eq!(result, 1_050_000_000_000i128); + } + + #[test] + fn test_apply_max_fee_slippage_small_value() { + let result = apply_max_fee_slippage(100); + assert_eq!(result, 105); + } + + // ============================================================================ + // Tests for apply_max_fee_slippage_with_bps (direct) + // ============================================================================ + + #[test] + fn test_apply_max_fee_slippage_with_bps_zero_slippage() { + // 0 BPS = no slippage + let result = apply_max_fee_slippage_with_bps(10000, 0); + assert_eq!(result, 10000); + } + + #[test] + fn test_apply_max_fee_slippage_with_bps_one_percent() { + // 100 BPS = 1% + let result = apply_max_fee_slippage_with_bps(10000, 100); + assert_eq!(result, 10100); + } + + #[test] + fn test_apply_max_fee_slippage_with_bps_ten_percent() { + // 1000 BPS = 10% + let result = apply_max_fee_slippage_with_bps(10000, 1000); + assert_eq!(result, 11000); + } + + #[test] + fn test_apply_max_fee_slippage_with_bps_hundred_percent() { + // 10000 BPS = 100% (double) + let result = apply_max_fee_slippage_with_bps(10000, 10000); + assert_eq!(result, 20000); + } + + #[test] + fn test_apply_max_fee_slippage_with_bps_zero_fee() { + let result = apply_max_fee_slippage_with_bps(0, 500); + assert_eq!(result, 0); + } + + #[test] + fn test_apply_max_fee_slippage_with_bps_large_fee() { + let large_fee: u64 = 1_000_000_000_000; + // 5% slippage + let result = apply_max_fee_slippage_with_bps(large_fee, 500); + assert_eq!(result, 1_050_000_000_000i128); + } + + #[test] + fn test_apply_max_fee_slippage_with_bps_small_fee_rounds_down() { + // 1 unit with 1 BPS (0.01%) — should round down to 1 + let result = apply_max_fee_slippage_with_bps(1, 1); + // (1 * 10001) / 10000 = 1 (integer division) + assert_eq!(result, 1); + } + + // ============================================================================ + // Tests for get_expiration_ledger + // ============================================================================ + + #[tokio::test] + async fn test_get_expiration_ledger_success() { + let mut provider = MockStellarProviderTrait::new(); + provider.expect_get_latest_ledger().returning(|| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { + id: "test".to_string(), + protocol_version: 20, + sequence: 1000, + }, + ))) + }); + + let result = get_expiration_ledger(&provider, 300).await; + assert!(result.is_ok()); + let expiration = result.unwrap(); + assert_eq!(expiration, 1060); // 1000 + 60 + } + + #[tokio::test] + async fn test_get_expiration_ledger_zero_seconds() { + let mut provider = MockStellarProviderTrait::new(); + provider.expect_get_latest_ledger().returning(|| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { + id: "test".to_string(), + protocol_version: 20, + sequence: 1000, + }, + ))) + }); + + let result = get_expiration_ledger(&provider, 0).await; + assert!(result.is_ok()); + let expiration = result.unwrap(); + assert_eq!(expiration, 1001); // 1000 + 1 (minimum) + } + + #[tokio::test] + async fn test_get_expiration_ledger_provider_error() { + use crate::services::provider::ProviderError; + + let mut provider = MockStellarProviderTrait::new(); + provider.expect_get_latest_ledger().returning(|| { + Box::pin(ready(Err(ProviderError::Other( + "network error".to_string(), + )))) + }); + + let result = get_expiration_ledger(&provider, 300).await; + assert!(result.is_err()); + match result.unwrap_err() { + RelayerError::Internal(msg) => { + assert!(msg.contains("Failed to get latest ledger")); + } + _ => panic!("Expected Internal error"), + } + } + + #[tokio::test] + async fn test_get_expiration_ledger_non_divisible_seconds() { + // 7 seconds / 5 seconds per ledger = 2 ledgers (div_ceil) + let mut provider = MockStellarProviderTrait::new(); + provider.expect_get_latest_ledger().returning(|| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { + id: "test".to_string(), + protocol_version: 20, + sequence: 1000, + }, + ))) + }); + + let result = get_expiration_ledger(&provider, 7).await; + assert!(result.is_ok()); + let expiration = result.unwrap(); + assert_eq!(expiration, 1002); // 1000 + ceil(7/5) = 1000 + 2 + } + + #[tokio::test] + async fn test_get_expiration_ledger_one_second() { + let mut provider = MockStellarProviderTrait::new(); + provider.expect_get_latest_ledger().returning(|| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { + id: "test".to_string(), + protocol_version: 20, + sequence: 500, + }, + ))) + }); + + let result = get_expiration_ledger(&provider, 1).await; + assert!(result.is_ok()); + let expiration = result.unwrap(); + assert_eq!(expiration, 501); // 500 + ceil(1/5) = 500 + 1 + } + + #[tokio::test] + async fn test_get_expiration_ledger_sequence_near_max() { + // Test saturating_add behavior near u32::MAX + let mut provider = MockStellarProviderTrait::new(); + provider.expect_get_latest_ledger().returning(|| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { + id: "test".to_string(), + protocol_version: 20, + sequence: u32::MAX - 1, + }, + ))) + }); + + let result = get_expiration_ledger(&provider, 300).await; + assert!(result.is_ok()); + let expiration = result.unwrap(); + // saturating_add should cap at u32::MAX + assert_eq!(expiration, u32::MAX); + } + + #[tokio::test] + async fn test_get_expiration_ledger_exact_ledger_time() { + // Exactly STELLAR_LEDGER_TIME_SECONDS (5 seconds) = 1 ledger + let mut provider = MockStellarProviderTrait::new(); + provider.expect_get_latest_ledger().returning(|| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { + id: "test".to_string(), + protocol_version: 20, + sequence: 2000, + }, + ))) + }); + + let result = get_expiration_ledger(&provider, STELLAR_LEDGER_TIME_SECONDS).await; + assert!(result.is_ok()); + let expiration = result.unwrap(); + assert_eq!(expiration, 2001); // 2000 + 1 + } +} diff --git a/src/domain/transaction/mod.rs b/src/domain/transaction/mod.rs index 5523ce9a1..5d5c7b41e 100644 --- a/src/domain/transaction/mod.rs +++ b/src/domain/transaction/mod.rs @@ -16,7 +16,7 @@ use crate::{ jobs::{JobProducer, StatusCheckContext}, models::{ EvmNetwork, NetworkTransactionRequest, NetworkType, RelayerRepoModel, SignerRepoModel, - SolanaNetwork, StellarNetwork, TransactionError, TransactionRepoModel, + SolanaNetwork, StellarNetwork, StellarSwapStrategy, TransactionError, TransactionRepoModel, }, repositories::{ NetworkRepository, NetworkRepositoryStorage, RelayerRepositoryStorage, @@ -29,7 +29,7 @@ use crate::{ }, provider::get_network_provider, signer::{EvmSignerFactory, SolanaSignerFactory, StellarSignerFactory}, - stellar_dex::OrderBookService, + stellar_dex::{DexServiceWrapper, OrderBookService, SoroswapService, StellarDexService}, }, }; use async_trait::async_trait; @@ -635,7 +635,7 @@ impl RelayerTransactionFactory { get_network_provider(&network, relayer.custom_rpc_urls.clone()) .map_err(|e| TransactionError::NetworkConfiguration(e.to_string()))?; - // Create DEX service for swap operations and validations using Horizon API + // Create DEX service for swap operations and validations let horizon_url = network.horizon_url.clone().unwrap_or_else(|| { if network.is_testnet() { STELLAR_HORIZON_TESTNET_URL.to_string() @@ -646,13 +646,80 @@ impl RelayerTransactionFactory { let provider_arc = Arc::new(stellar_provider.clone()); // Clone Arc for DEX service (cheap - just increments reference count) let signer_arc = signer_service.clone(); - let dex_service = Arc::new( - OrderBookService::new(horizon_url, provider_arc, signer_arc).map_err(|e| { - TransactionError::NetworkConfiguration(format!( - "Failed to create DEX service: {e}", - )) - })?, - ); + + // Get strategies from relayer policy (default to OrderBook if none specified) + let strategies = relayer + .policies + .get_stellar_policy() + .get_swap_config() + .and_then(|config| { + if config.strategies.is_empty() { + None + } else { + Some(config.strategies.clone()) + } + }) + .unwrap_or_else(|| vec![StellarSwapStrategy::OrderBook]); + + // Create DEX services for each configured strategy + let mut dex_services: Vec> = Vec::new(); + for strategy in &strategies { + match strategy { + StellarSwapStrategy::OrderBook => { + let order_book_service = Arc::new( + OrderBookService::new( + horizon_url.clone(), + provider_arc.clone(), + signer_arc.clone(), + ) + .map_err(|e| { + TransactionError::NetworkConfiguration(format!( + "Failed to create OrderBook DEX service: {e}" + )) + })?, + ); + dex_services.push(DexServiceWrapper::OrderBook(order_book_service)); + } + StellarSwapStrategy::Soroswap => { + let is_testnet = network.is_testnet(); + let network_label = if is_testnet { "TESTNET" } else { "MAINNET" }; + + let router_address = + crate::config::ServerConfig::resolve_stellar_soroswap_router_address(is_testnet) + .ok_or_else(|| { + eyre::eyre!( + "Soroswap router address not configured. Set STELLAR_{network_label}_SOROSWAP_ROUTER_ADDRESS env var." + ) + })?; + let factory_address = + crate::config::ServerConfig::resolve_stellar_soroswap_factory_address(is_testnet) + .ok_or_else(|| { + eyre::eyre!( + "Soroswap factory address not configured. Set STELLAR_{network_label}_SOROSWAP_FACTORY_ADDRESS env var." + ) + })?; + let native_wrapper_address = + crate::config::ServerConfig::resolve_stellar_soroswap_native_wrapper_address(is_testnet) + .ok_or_else(|| { + eyre::eyre!( + "Soroswap native wrapper address not configured. Set STELLAR_{network_label}_SOROSWAP_NATIVE_WRAPPER_ADDRESS env var." + ) + })?; + + let soroswap_service = Arc::new(SoroswapService::new( + router_address, + factory_address, + native_wrapper_address, + provider_arc.clone(), + network.passphrase.clone(), + )); + dex_services.push(DexServiceWrapper::Soroswap(soroswap_service)); + } + } + } + + // Create multi-strategy DEX service + let dex_service = Arc::new(StellarDexService::new(dex_services)); Ok(NetworkTransaction::Stellar(DefaultStellarTransaction::new( relayer, diff --git a/src/domain/transaction/stellar/prepare/mod.rs b/src/domain/transaction/stellar/prepare/mod.rs index ae73a3d42..38115068e 100644 --- a/src/domain/transaction/stellar/prepare/mod.rs +++ b/src/domain/transaction/stellar/prepare/mod.rs @@ -6,6 +6,7 @@ pub mod common; pub mod fee_bump; pub mod operations; +pub mod soroban_gas_abstraction; pub mod unsigned_xdr; use eyre::Result; @@ -20,7 +21,10 @@ use crate::{ TransactionUpdateRequest, }, repositories::{Repository, TransactionCounterTrait, TransactionRepository}, - services::{provider::StellarProviderTrait, signer::Signer}, + services::{ + provider::StellarProviderTrait, + signer::{Signer, StellarSignTrait}, + }, }; use common::{sign_and_finalize_transaction, update_and_notify_transaction}; @@ -30,7 +34,7 @@ where R: Repository + Send + Sync, T: TransactionRepository + Send + Sync, J: JobProducerTrait + Send + Sync, - S: Signer + Send + Sync, + S: Signer + StellarSignTrait + Send + Sync, P: StellarProviderTrait + Send + Sync, C: TransactionCounterTrait + Send + Sync, D: crate::services::stellar_dex::StellarDexServiceTrait + Send + Sync + 'static, @@ -168,6 +172,22 @@ where ) .await } + TransactionInput::SorobanGasAbstraction { .. } => { + debug!(tx_id = %tx.id, "preparing soroban gas abstraction transaction"); + let stellar_data_with_auth = + soroban_gas_abstraction::process_soroban_gas_abstraction( + self.transaction_counter_service(), + &self.relayer().id, + &self.relayer().address, + self.provider(), + stellar_data, + Some(&policy), + self.dex_service(), + ) + .await?; + self.finalize_with_signature(tx, stellar_data_with_auth) + .await + } } } diff --git a/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs b/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs new file mode 100644 index 000000000..7db65f37a --- /dev/null +++ b/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs @@ -0,0 +1,2270 @@ +//! This module handles the preparation of Soroban gas abstraction transactions. +//! These are transactions where the user pays fees in tokens via the FeeForwarder contract. +//! The user signs an authorization entry, which is injected into the transaction before submission. +//! The relayer also signs its own authorization entry for the FeeForwarder contract. + +use soroban_rs::xdr::{ + InvokeHostFunctionOp, Limits, Operation, OperationBody, ReadXdr, ScAddress, ScVal, + SorobanAuthorizationEntry, SorobanAuthorizedFunction, SorobanCredentials, SorobanResources, + SorobanTransactionData, TransactionEnvelope, TransactionExt, WriteXdr, +}; +use tracing::{debug, info, warn}; + +use crate::{ + constants::STELLAR_DEFAULT_TRANSACTION_FEE, + domain::transaction::stellar::{ + utils::{convert_xlm_fee_to_token, envelope_fee_in_stroops, update_envelope_sequence}, + StellarTransactionValidator, + }, + models::{RelayerStellarPolicy, StellarTransactionData, TransactionError, TransactionInput}, + repositories::TransactionCounterTrait, + services::{ + provider::{StellarProvider, StellarProviderTrait}, + stellar_dex::StellarDexServiceTrait, + stellar_fee_forwarder::{FeeForwarderError, FeeForwarderService}, + }, +}; + +use super::common::get_next_sequence; + +/// Process a Soroban gas abstraction transaction. +/// +/// This function: +/// 1. Parses the FeeForwarder transaction XDR +/// 2. Deserializes the user's signed authorization entry +/// 3. Validates the fee parameters ensure relayer liquidity (token allowed, amount sufficient) +/// 4. Signs the relayer's authorization entry using the provided signer +/// 5. Injects both signed auth entries into the transaction +/// 6. Re-simulates with signed auth entries to get accurate footprint +/// 7. Updates the transaction's sorobanData with accurate resources +/// 8. Updates the sequence number +/// 9. Returns the prepared transaction data for signing +/// +/// # Arguments +/// +/// * `counter_service` - Service for managing sequence numbers +/// * `relayer_id` - The relayer's ID for sequence tracking +/// * `relayer_address` - The relayer's Stellar address (source account for the transaction) +/// * `provider` - The Stellar provider for simulation +/// * `stellar_data` - The transaction data containing the XDR and signed auth entry +/// * `policy` - Optional relayer policy for fee validation +/// * `dex_service` - DEX service for token-to-XLM conversion quotes +pub async fn process_soroban_gas_abstraction( + counter_service: &C, + relayer_id: &str, + relayer_address: &str, + provider: &P, + mut stellar_data: StellarTransactionData, + policy: Option<&RelayerStellarPolicy>, + dex_service: &D, +) -> Result +where + C: TransactionCounterTrait + Send + Sync, + P: StellarProviderTrait + Send + Sync, + D: StellarDexServiceTrait + Send + Sync, +{ + // Extract XDR and signed auth entry from transaction input + let (xdr, signed_auth_entry_xdr) = match &stellar_data.transaction_input { + TransactionInput::SorobanGasAbstraction { + xdr, + signed_auth_entry, + } => (xdr.clone(), signed_auth_entry.clone()), + _ => { + return Err(TransactionError::ValidationError( + "Expected SorobanGasAbstraction transaction input".to_string(), + )); + } + }; + + debug!( + "Processing Soroban gas abstraction: xdr_len={}, auth_entry_len={}", + xdr.len(), + signed_auth_entry_xdr.len() + ); + + // Parse the transaction envelope + let mut envelope = TransactionEnvelope::from_xdr_base64(&xdr, Limits::none()).map_err(|e| { + TransactionError::ValidationError(format!("Failed to parse transaction XDR: {e}")) + })?; + + // Deserialize the user's signed authorization entry + let signed_user_auth = + FeeForwarderService::::deserialize_auth_entry(&signed_auth_entry_xdr) + .map_err(|e| match e { + FeeForwarderError::XdrError(msg) => TransactionError::ValidationError(msg), + _ => TransactionError::ValidationError(format!( + "Failed to deserialize signed auth entry: {e}" + )), + })?; + + // Inject the user's signed auth entry and convert relayer's auth to SourceAccount + // This must happen before re-simulation so the simulation uses proper auth entries. + let signed_auth_entries = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth)?; + + // Re-simulate with signed auth entries to get accurate footprint and resources. + // This may bump the envelope fee when the new required fee is higher (still under max_fee + // check in validation below). + // + // According to Soroban flow, after signing auth entries you must re-simulate: + // 1. Simulation validates the signatures + // 2. Calculates ledger resources accurately + // 3. The footprint will include all accounts accessed via require_auth/require_auth_for_args + // 4. Returns a fully-resourced transaction ready for submission + simulate_and_update_resources(&mut envelope, &signed_auth_entries, provider).await?; + + // Validate fee parameters after resource update: token allowed, max_fee_amount covers + // the (possibly bumped) required fee. We use the envelope's current fee as required_xlm_fee + // to avoid a second simulation. + if let Some(policy) = policy { + let required_xlm_fee = envelope_fee_in_stroops(&envelope) + .map_err(|e| TransactionError::ValidationError(e.to_string()))?; + validate_gas_abstraction_fee( + &envelope, + &signed_auth_entries[0], + policy, + Some(required_xlm_fee), + provider, + dex_service, + ) + .await?; + } + + // Get the next sequence number for the relayer + let sequence_number = get_next_sequence(counter_service, relayer_id, relayer_address).await?; + + // Update the sequence number in the envelope + update_envelope_sequence(&mut envelope, sequence_number) + .map_err(|e| TransactionError::ValidationError(e.to_string()))?; + + // Serialize the updated envelope back to XDR + let updated_xdr = envelope.to_xdr_base64(Limits::none()).map_err(|e| { + TransactionError::UnexpectedError(format!("Failed to serialize updated envelope: {e}")) + })?; + + // Update the transaction data with the new XDR and sequence number + stellar_data.sequence_number = Some(sequence_number); + stellar_data.transaction_input = TransactionInput::UnsignedXdr(updated_xdr); + + debug!( + "Soroban gas abstraction prepared: sequence={}", + sequence_number + ); + + Ok(stellar_data) +} + +/// Inject signed authorization entries into the transaction envelope. +/// +/// For FeeForwarder transactions, there are two auth entries: +/// 1. User's auth entry (first) - already signed by the user +/// 2. Relayer's auth entry (second) - uses SourceAccount credentials (no separate signature needed) +/// +/// This function: +/// - Replaces the first auth entry with the user's signed version +/// - Converts the relayer's auth entry to use SourceAccount credentials +/// - Returns the auth entries for use in simulation +fn inject_auth_entries_into_envelope( + envelope: &mut TransactionEnvelope, + signed_user_auth: SorobanAuthorizationEntry, +) -> Result, TransactionError> { + let tx = match envelope { + TransactionEnvelope::Tx(v1) => &mut v1.tx, + TransactionEnvelope::TxV0(_) => { + return Err(TransactionError::ValidationError( + "V0 transactions are not supported for Soroban".to_string(), + )); + } + TransactionEnvelope::TxFeeBump(_) => { + return Err(TransactionError::ValidationError( + "Fee bump transactions should not be used for Soroban gas abstraction".to_string(), + )); + } + }; + + // Get the operation (Soroban transactions must have exactly one operation) + let operations: Vec<_> = tx.operations.to_vec(); + if operations.is_empty() { + return Err(TransactionError::ValidationError( + "Transaction has no operations".to_string(), + )); + } + + // Soroban protocol constraint: transactions must contain exactly one operation + if operations.len() != 1 { + return Err(TransactionError::ValidationError(format!( + "Soroban transactions must contain exactly one operation, found {}", + operations.len() + ))); + } + + let first_op = &operations[0]; + let invoke_op = match &first_op.body { + OperationBody::InvokeHostFunction(invoke) => invoke.clone(), + _ => { + return Err(TransactionError::ValidationError( + "First operation is not InvokeHostFunction".to_string(), + )); + } + }; + + // The auth entries should have user's auth entry as the first entry, relayer's as second + let mut auth_entries: Vec = invoke_op.auth.to_vec(); + + if auth_entries.is_empty() { + return Err(TransactionError::ValidationError( + "FeeForwarder transaction must contain auth entries. The transaction may be \ + malformed or was not built via the /build endpoint." + .to_string(), + )); + } + + // Replace the first auth entry (user's) with the signed version + auth_entries[0] = signed_user_auth; + + // Convert the relayer's auth entry (second entry) to use SourceAccount credentials. + // Since the relayer is the transaction source account, the transaction signature + // already authorizes this entry - no separate auth entry signature is needed. + if auth_entries.len() > 1 { + let relayer_auth = &auth_entries[1]; + let source_account_auth = SorobanAuthorizationEntry { + credentials: SorobanCredentials::SourceAccount, + root_invocation: relayer_auth.root_invocation.clone(), + }; + auth_entries[1] = source_account_auth; + debug!("Converted relayer auth entry to SourceAccount credentials"); + } + + // Clone auth_entries before consuming them in try_into (we need to return them) + let result_auth_entries = auth_entries.clone(); + + // Create the updated InvokeHostFunction operation + let updated_invoke = soroban_rs::xdr::InvokeHostFunctionOp { + host_function: invoke_op.host_function, + auth: auth_entries.try_into().map_err(|_| { + TransactionError::UnexpectedError("Failed to create auth entries vector".to_string()) + })?, + }; + + // Create the updated operation + let updated_op = soroban_rs::xdr::Operation { + source_account: first_op.source_account.clone(), + body: OperationBody::InvokeHostFunction(updated_invoke), + }; + + // Replace the first operation with the updated one + let mut updated_operations = operations; + updated_operations[0] = updated_op; + + // Update the transaction's operations + tx.operations = updated_operations.try_into().map_err(|_| { + TransactionError::UnexpectedError("Failed to update operations vector".to_string()) + })?; + + debug!("Successfully injected signed auth entries into transaction"); + + Ok(result_auth_entries) +} + +/// Apply a buffer to Soroban resources to account for simulation variance. +/// +/// Simulation can be slightly inaccurate due to timing differences or other factors. +/// Adding a 15% buffer prevents "exceeded limit" errors during execution. +/// Uses saturating arithmetic to prevent silent truncation if scaled values exceed u32::MAX. +fn apply_resource_buffer(resources: &mut SorobanResources) { + const BUFFER_MULTIPLIER: u64 = 115; + + let scale = |value: u32| -> u32 { + ((value as u64).saturating_mul(BUFFER_MULTIPLIER) / 100).min(u32::MAX as u64) as u32 + }; + + resources.instructions = scale(resources.instructions); + resources.disk_read_bytes = scale(resources.disk_read_bytes); + resources.write_bytes = scale(resources.write_bytes); +} + +/// Re-simulate the transaction with signed auth entries and update resources. +/// +/// This function: +/// 1. Builds a simulation envelope with the actual signed auth entries +/// 2. Simulates to get accurate footprint and resources +/// 3. Updates the original envelope's sorobanData with the accurate values +/// +/// Using the actual signed auth entries allows the simulation to: +/// - Verify signatures (they should be valid since values haven't changed) +/// - Execute the full auth verification code path +/// - Capture the correct footprint including accounts accessed via require_auth +async fn simulate_and_update_resources

( + envelope: &mut TransactionEnvelope, + signed_auth_entries: &[SorobanAuthorizationEntry], + provider: &P, +) -> Result<(), TransactionError> +where + P: StellarProviderTrait + Send + Sync, +{ + debug!("Re-simulating transaction with signed auth entries for accurate footprint"); + + // Use the actual signed auth entries for simulation + // This allows the simulation to verify signatures and capture the correct footprint + // including all accounts accessed via require_auth/require_auth_for_args + let simulation_auth_entries: Vec = signed_auth_entries.to_vec(); + + // Build simulation envelope (clone the original and replace auth entries) + let simulation_envelope = build_simulation_envelope(envelope, &simulation_auth_entries)?; + + // Simulate the transaction + let sim_response = provider + .simulate_transaction_envelope(&simulation_envelope) + .await + .map_err(|e| { + TransactionError::UnexpectedError(format!("Failed to simulate transaction: {e}")) + })?; + + // Check for simulation errors + if let Some(err) = &sim_response.error { + return Err(TransactionError::UnexpectedError(format!( + "Simulation failed: {err}" + ))); + } + + // Parse the new transaction data from simulation + let mut new_tx_data = + SorobanTransactionData::from_xdr_base64(&sim_response.transaction_data, Limits::none()) + .map_err(|e| { + TransactionError::UnexpectedError(format!( + "Failed to parse simulation transaction_data: {e}" + )) + })?; + + // Log the resource values from simulation (before buffer) + debug!( + "Simulation complete: instructions={}, read_bytes={}, write_bytes={}", + new_tx_data.resources.instructions, + new_tx_data.resources.disk_read_bytes, + new_tx_data.resources.write_bytes + ); + + // Apply buffer to resources to account for simulation variance + apply_resource_buffer(&mut new_tx_data.resources); + + // Log the resource values after buffer + info!( + "Resources after buffer: instructions={}, read_bytes={}, write_bytes={}", + new_tx_data.resources.instructions, + new_tx_data.resources.disk_read_bytes, + new_tx_data.resources.write_bytes + ); + + // Compute required XLM fee from this simulation (inclusion + resource fee) + let inclusion_fee = STELLAR_DEFAULT_TRANSACTION_FEE as u64; + let new_required_xlm_fee = inclusion_fee + sim_response.min_resource_fee; + let new_fee_u32 = new_required_xlm_fee.min(u32::MAX as u64) as u32; + + // Update the original envelope's sorobanData with accurate resources + // Bump fee when the new required fee is higher (still validated against max_fee_amount later) + match envelope { + TransactionEnvelope::Tx(ref mut env) => { + let original_fee = env.tx.fee; + let fee_to_set = if new_fee_u32 > original_fee { + debug!( + "Bumping envelope fee from {} to {} (required by updated simulation)", + original_fee, new_fee_u32 + ); + new_fee_u32 + } else { + original_fee + }; + + // Update the transaction extension with new soroban data + env.tx.ext = TransactionExt::V1(new_tx_data); + env.tx.fee = fee_to_set; + + debug!( + "Updated transaction sorobanData with simulation results, fee={}", + fee_to_set + ); + Ok(()) + } + _ => Err(TransactionError::ValidationError( + "Expected V1 transaction envelope".to_string(), + )), + } +} + +/// Build a simulation envelope with the provided auth entries. +/// +/// This creates a copy of the envelope with the specified auth entries. +/// The auth entries should be the actual signed entries to ensure proper +/// signature verification and footprint capture during simulation. +fn build_simulation_envelope( + original: &TransactionEnvelope, + simulation_auth_entries: &[SorobanAuthorizationEntry], +) -> Result { + match original { + TransactionEnvelope::Tx(env) => { + let mut sim_tx = env.tx.clone(); + + // Get the operations and update the auth entries + let operations: Vec<_> = sim_tx.operations.to_vec(); + if operations.is_empty() { + return Err(TransactionError::ValidationError( + "Transaction has no operations".to_string(), + )); + } + + let first_op = &operations[0]; + let invoke_op = match &first_op.body { + OperationBody::InvokeHostFunction(invoke) => invoke.clone(), + _ => { + return Err(TransactionError::ValidationError( + "First operation is not InvokeHostFunction".to_string(), + )); + } + }; + + // Create updated invoke operation with simulation auth entries + let updated_invoke = InvokeHostFunctionOp { + host_function: invoke_op.host_function, + auth: simulation_auth_entries.to_vec().try_into().map_err(|_| { + TransactionError::UnexpectedError( + "Failed to create simulation auth entries".to_string(), + ) + })?, + }; + + let updated_op = Operation { + source_account: first_op.source_account.clone(), + body: OperationBody::InvokeHostFunction(updated_invoke), + }; + + let mut updated_operations = operations; + updated_operations[0] = updated_op; + + sim_tx.operations = updated_operations.try_into().map_err(|_| { + TransactionError::UnexpectedError("Failed to update operations".to_string()) + })?; + + Ok(TransactionEnvelope::Tx( + soroban_rs::xdr::TransactionV1Envelope { + tx: sim_tx, + signatures: Default::default(), + }, + )) + } + _ => Err(TransactionError::ValidationError( + "Expected V1 transaction envelope".to_string(), + )), + } +} + +// ============================================================================ +// Fee Validation for Soroban Gas Abstraction +// ============================================================================ + +/// Validate that the FeeForwarder transaction parameters ensure relayer liquidity. +/// +/// This function validates that the user's signed authorization entry contains: +/// 1. An allowed fee token (per relayer policy) +/// 2. A max_fee_amount sufficient to cover the required network fee (converted via DEX) +/// +/// This validation is critical to prevent malicious users from submitting transactions +/// where the token payment doesn't adequately compensate the relayer for XLM fees. +/// +/// # Arguments +/// +/// * `envelope` - The transaction envelope (used to estimate required XLM fee when override is None) +/// * `signed_user_auth` - The user's signed authorization entry +/// * `policy` - The relayer policy containing allowed tokens and fee settings +/// * `required_xlm_fee_override` - If Some, use this as the required XLM fee (e.g. envelope's +/// current fee after simulate_and_update_resources). If None, estimate via simulation. +/// * `provider` - Provider for Stellar RPC operations (fee estimation when override is None) +/// * `dex_service` - DEX service for token-to-XLM conversion quotes +/// +/// # Returns +/// +/// Ok(()) if validation passes, TransactionError if validation fails +async fn validate_gas_abstraction_fee( + envelope: &TransactionEnvelope, + signed_user_auth: &SorobanAuthorizationEntry, + policy: &RelayerStellarPolicy, + required_xlm_fee_override: Option, + provider: &P, + dex_service: &D, +) -> Result<(), TransactionError> +where + P: StellarProviderTrait + Send + Sync, + D: StellarDexServiceTrait + Send + Sync, +{ + // Step 1: Extract fee parameters from the user's signed auth entry + let (fee_token, max_fee_amount) = extract_fee_params_from_auth(signed_user_auth)?; + + debug!( + "Validating gas abstraction fee: token={}, max_fee_amount={}", + fee_token, max_fee_amount + ); + + // Step 2: Validate the fee token is allowed by policy + StellarTransactionValidator::validate_allowed_token(&fee_token, policy).map_err(|e| { + TransactionError::ValidationError(format!("Fee token validation failed: {e}")) + })?; + + // Step 3: Validate max_fee_amount against policy's max_allowed_fee (if configured) + if max_fee_amount <= 0 { + return Err(TransactionError::ValidationError( + "max_fee_amount must be positive".to_string(), + )); + } + StellarTransactionValidator::validate_token_max_fee(&fee_token, max_fee_amount as u64, policy) + .map_err(|e| { + TransactionError::ValidationError(format!("Max fee validation failed: {e}")) + })?; + + // Step 4: Required XLM fee - use override (e.g. envelope fee after bump) or estimate + let required_xlm_fee = match required_xlm_fee_override { + Some(fee) => fee, + None => estimate_required_xlm_fee(envelope, provider).await?, + }; + + debug!( + "Required XLM fee: {} stroops for gas abstraction transaction", + required_xlm_fee + ); + + // Step 5: Convert the required XLM fee to token amount + let fee_quote = convert_xlm_fee_to_token(dex_service, policy, required_xlm_fee, &fee_token) + .await + .map_err(|e| { + TransactionError::ValidationError(format!( + "Failed to convert XLM fee to token {fee_token}: {e}" + )) + })?; + + // Step 6: Validate that max_fee_amount covers the required fee in tokens + let required_token_fee = fee_quote.fee_in_token as i128; + if max_fee_amount < required_token_fee { + return Err(TransactionError::ValidationError(format!( + "Insufficient max_fee_amount: user authorized {max_fee_amount} but required fee is {required_token_fee} (in token {fee_token}). \ + This would result in relayer liquidity loss." + ))); + } + + info!( + "Gas abstraction fee validation passed: max_fee_amount={} >= required={} (token={})", + max_fee_amount, required_token_fee, fee_token + ); + + Ok(()) +} + +/// Extract fee parameters from the user's signed FeeForwarder authorization entry. +/// +/// The user's auth entry for FeeForwarder.forward() contains these arguments: +/// - fee_token (arg 0): Address of the token contract +/// - max_fee_amount (arg 1): Maximum fee amount user authorized (i128) +/// - expiration_ledger (arg 2): When the authorization expires +/// - target_contract (arg 3): Contract being called +/// - target_fn (arg 4): Function being called +/// - target_args (arg 5): Arguments to the target function +/// +/// # Arguments +/// +/// * `auth` - The user's signed authorization entry +/// +/// # Returns +/// +/// A tuple of (fee_token_address, max_fee_amount) or an error if extraction fails +fn extract_fee_params_from_auth( + auth: &SorobanAuthorizationEntry, +) -> Result<(String, i128), TransactionError> { + // Extract the function arguments from the root invocation + let args = match &auth.root_invocation.function { + SorobanAuthorizedFunction::ContractFn(invoke) => invoke.args.to_vec(), + _ => { + return Err(TransactionError::ValidationError( + "Expected ContractFn in auth entry root invocation".to_string(), + )); + } + }; + + // User auth args should have at least 2 arguments: fee_token, max_fee_amount + if args.len() < 2 { + return Err(TransactionError::ValidationError(format!( + "Invalid auth entry: expected at least 2 arguments, found {}", + args.len() + ))); + } + + // Extract fee_token (arg 0) - should be an Address (contract) + let fee_token = extract_contract_address_from_scval(&args[0]).map_err(|e| { + TransactionError::ValidationError(format!("Failed to extract fee_token: {e}")) + })?; + + // Extract max_fee_amount (arg 1) - should be an I128 + let max_fee_amount = extract_i128_from_scval(&args[1]).map_err(|e| { + TransactionError::ValidationError(format!("Failed to extract max_fee_amount: {e}")) + })?; + + Ok((fee_token, max_fee_amount)) +} + +/// Extract a contract address string from an ScVal::Address. +/// +/// Converts a Soroban contract address to its string representation (C...). +fn extract_contract_address_from_scval(val: &ScVal) -> Result { + match val { + ScVal::Address(ScAddress::Contract(contract_id)) => { + let strkey = stellar_strkey::Contract(contract_id.0 .0); + Ok(strkey.to_string()) + } + ScVal::Address(ScAddress::Account(_)) => { + Err("Expected contract address, found account address".to_string()) + } + _ => Err(format!("Expected Address, found {val:?}")), + } +} + +/// Extract an i128 value from an ScVal::I128. +fn extract_i128_from_scval(val: &ScVal) -> Result { + match val { + ScVal::I128(parts) => { + let value = ((parts.hi as i128) << 64) | (parts.lo as i128); + Ok(value) + } + _ => Err(format!("Expected I128, found {val:?}")), + } +} + +/// Estimate the required XLM fee for a Soroban gas abstraction transaction. +/// +/// This performs a simulation to get accurate resource requirements and calculates +/// the total fee (inclusion fee + resource fee). +async fn estimate_required_xlm_fee

( + envelope: &TransactionEnvelope, + provider: &P, +) -> Result +where + P: StellarProviderTrait + Send + Sync, +{ + // Simulate the transaction to get resource fee + let sim_response = provider + .simulate_transaction_envelope(envelope) + .await + .map_err(|e| { + TransactionError::UnexpectedError(format!( + "Failed to simulate transaction for fee estimation: {e}" + )) + })?; + + // Check for simulation errors + if let Some(err) = &sim_response.error { + warn!("Simulation returned error during fee estimation: {}", err); + // Still try to use the fee if available, otherwise return error + if sim_response.min_resource_fee == 0 { + return Err(TransactionError::ValidationError(format!( + "Simulation failed during fee estimation: {err}" + ))); + } + } + + // Calculate total fee: inclusion fee (100 stroops) + resource fee + let inclusion_fee = crate::constants::STELLAR_DEFAULT_TRANSACTION_FEE as u64; + let resource_fee = sim_response.min_resource_fee; + let total_fee = inclusion_fee + resource_fee; + + debug!( + "Estimated XLM fee: inclusion={}, resource={}, total={}", + inclusion_fee, resource_fee, total_fee + ); + + Ok(total_fee) +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_rs::xdr::{ + ContractId, FeeBumpTransaction, FeeBumpTransactionEnvelope, FeeBumpTransactionExt, + FeeBumpTransactionInnerTx, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, + Memo, MuxedAccount, Preconditions, ScAddress, ScSymbol, ScVal, SequenceNumber, + SorobanAddressCredentials, SorobanAuthorizedFunction, SorobanAuthorizedInvocation, + Transaction, TransactionExt, TransactionV0, TransactionV0Envelope, TransactionV0Ext, + TransactionV1Envelope, Uint256, VecM, + }; + + // Helper to create a minimal V1 transaction envelope + fn create_minimal_v1_envelope() -> TransactionEnvelope { + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])), + fee: 100, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: VecM::default(), + ext: TransactionExt::V0, + }; + + TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }) + } + + // Helper to create a V0 transaction envelope + fn create_v0_envelope() -> TransactionEnvelope { + let tx = TransactionV0 { + source_account_ed25519: Uint256([0u8; 32]), + fee: 100, + seq_num: SequenceNumber(0), + time_bounds: None, + memo: Memo::None, + operations: VecM::default(), + ext: TransactionV0Ext::V0, + }; + + TransactionEnvelope::TxV0(TransactionV0Envelope { + tx, + signatures: VecM::default(), + }) + } + + // Helper to create a fee bump transaction envelope + fn create_fee_bump_envelope() -> TransactionEnvelope { + let inner_tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])), + fee: 100, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: VecM::default(), + ext: TransactionExt::V0, + }; + + let inner_envelope = TransactionV1Envelope { + tx: inner_tx, + signatures: VecM::default(), + }; + + let fee_bump_tx = FeeBumpTransaction { + fee_source: MuxedAccount::Ed25519(Uint256([1u8; 32])), + fee: 200, + inner_tx: FeeBumpTransactionInnerTx::Tx(inner_envelope), + ext: FeeBumpTransactionExt::V0, + }; + + TransactionEnvelope::TxFeeBump(FeeBumpTransactionEnvelope { + tx: fee_bump_tx, + signatures: VecM::default(), + }) + } + + // Helper to create a V1 envelope with an InvokeHostFunction operation + fn create_invoke_host_function_envelope( + auth_entries: Vec, + ) -> TransactionEnvelope { + let invoke_op = InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("test".try_into().unwrap()), + args: VecM::default(), + }), + auth: auth_entries.try_into().unwrap_or_default(), + }; + + let operation = Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(invoke_op), + }; + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])), + fee: 100, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![operation].try_into().unwrap(), + ext: TransactionExt::V0, + }; + + TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }) + } + + // Helper to create a mock SorobanAuthorizationEntry with Address credentials + fn create_address_auth_entry() -> SorobanAuthorizationEntry { + SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + nonce: 0, + signature_expiration_ledger: 100, + signature: ScVal::Void, + }), + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("test".try_into().unwrap()), + args: VecM::default(), + }), + sub_invocations: VecM::default(), + }, + } + } + + // Helper to create a mock SorobanAuthorizationEntry with SourceAccount credentials + fn create_source_account_auth_entry() -> SorobanAuthorizationEntry { + SorobanAuthorizationEntry { + credentials: SorobanCredentials::SourceAccount, + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([1u8; 32]))), + function_name: ScSymbol("relayer_fn".try_into().unwrap()), + args: VecM::default(), + }), + sub_invocations: VecM::default(), + }, + } + } + + // ==================== apply_resource_buffer tests ==================== + + #[test] + fn test_apply_resource_buffer_standard_values() { + let mut resources = SorobanResources { + footprint: soroban_rs::xdr::LedgerFootprint { + read_only: VecM::default(), + read_write: VecM::default(), + }, + instructions: 1000, + disk_read_bytes: 2000, + write_bytes: 500, + }; + + apply_resource_buffer(&mut resources); + + // 15% buffer: value * 115 / 100 + assert_eq!(resources.instructions, 1150); + assert_eq!(resources.disk_read_bytes, 2300); + assert_eq!(resources.write_bytes, 575); + } + + #[test] + fn test_apply_resource_buffer_zero_values() { + let mut resources = SorobanResources { + footprint: soroban_rs::xdr::LedgerFootprint { + read_only: VecM::default(), + read_write: VecM::default(), + }, + instructions: 0, + disk_read_bytes: 0, + write_bytes: 0, + }; + + apply_resource_buffer(&mut resources); + + assert_eq!(resources.instructions, 0); + assert_eq!(resources.disk_read_bytes, 0); + assert_eq!(resources.write_bytes, 0); + } + + #[test] + fn test_apply_resource_buffer_large_values_no_overflow() { + let large_value = u32::MAX - 1000; + let mut resources = SorobanResources { + footprint: soroban_rs::xdr::LedgerFootprint { + read_only: VecM::default(), + read_write: VecM::default(), + }, + instructions: large_value, + disk_read_bytes: large_value, + write_bytes: large_value, + }; + + apply_resource_buffer(&mut resources); + + // Should saturate at u32::MAX, not overflow + assert!(resources.instructions <= u32::MAX); + assert!(resources.disk_read_bytes <= u32::MAX); + assert!(resources.write_bytes <= u32::MAX); + } + + #[test] + fn test_apply_resource_buffer_max_value_saturates() { + let mut resources = SorobanResources { + footprint: soroban_rs::xdr::LedgerFootprint { + read_only: VecM::default(), + read_write: VecM::default(), + }, + instructions: u32::MAX, + disk_read_bytes: u32::MAX, + write_bytes: u32::MAX, + }; + + apply_resource_buffer(&mut resources); + + // Should saturate at u32::MAX + assert_eq!(resources.instructions, u32::MAX); + assert_eq!(resources.disk_read_bytes, u32::MAX); + assert_eq!(resources.write_bytes, u32::MAX); + } + + #[test] + fn test_apply_resource_buffer_preserves_footprint() { + use soroban_rs::xdr::{LedgerFootprint, LedgerKey, LedgerKeyAccount}; + + let account_key = LedgerKey::Account(LedgerKeyAccount { + account_id: soroban_rs::xdr::AccountId( + soroban_rs::xdr::PublicKey::PublicKeyTypeEd25519(Uint256([0u8; 32])), + ), + }); + + let mut resources = SorobanResources { + footprint: LedgerFootprint { + read_only: vec![account_key.clone()].try_into().unwrap(), + read_write: VecM::default(), + }, + instructions: 1000, + disk_read_bytes: 1000, + write_bytes: 1000, + }; + + apply_resource_buffer(&mut resources); + + // Footprint should be unchanged + assert_eq!(resources.footprint.read_only.len(), 1); + } + + // ==================== inject_auth_entries_into_envelope tests ==================== + + #[test] + fn test_inject_auth_entries_v0_envelope_returns_error() { + let mut envelope = create_v0_envelope(); + let signed_user_auth = create_address_auth_entry(); + + let result = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth); + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!(msg.contains("V0 transactions are not supported for Soroban")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_inject_auth_entries_fee_bump_returns_error() { + let mut envelope = create_fee_bump_envelope(); + let signed_user_auth = create_address_auth_entry(); + + let result = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth); + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!(msg.contains("Fee bump transactions should not be used")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_inject_auth_entries_no_operations_returns_error() { + let mut envelope = create_minimal_v1_envelope(); + let signed_user_auth = create_address_auth_entry(); + + let result = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth); + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!(msg.contains("Transaction has no operations")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_inject_auth_entries_multiple_operations_returns_error() { + use soroban_rs::xdr::{Asset, PaymentOp}; + + // Create an InvokeHostFunction operation + let invoke_op = Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("test".try_into().unwrap()), + args: VecM::default(), + }), + auth: VecM::default(), + }), + }; + + // Create a second operation (Payment) + let payment_op = Operation { + source_account: None, + body: OperationBody::Payment(PaymentOp { + destination: MuxedAccount::Ed25519(Uint256([0u8; 32])), + asset: Asset::Native, + amount: 1000, + }), + }; + + // Create envelope with two operations (invalid for Soroban) + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])), + fee: 100, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![invoke_op, payment_op].try_into().unwrap(), + ext: TransactionExt::V0, + }; + + let mut envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }); + + let signed_user_auth = create_address_auth_entry(); + let result = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth); + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!( + msg.contains("Soroban transactions must contain exactly one operation"), + "Expected error about single operation, got: {}", + msg + ); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_inject_auth_entries_non_invoke_host_function_returns_error() { + use soroban_rs::xdr::{Asset, PaymentOp}; + + // Create envelope with a Payment operation (not InvokeHostFunction) + let payment_op = Operation { + source_account: None, + body: OperationBody::Payment(PaymentOp { + destination: MuxedAccount::Ed25519(Uint256([0u8; 32])), + asset: Asset::Native, + amount: 1000, + }), + }; + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])), + fee: 100, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![payment_op].try_into().unwrap(), + ext: TransactionExt::V0, + }; + + let mut envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }); + + let signed_user_auth = create_address_auth_entry(); + let result = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth); + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!(msg.contains("First operation is not InvokeHostFunction")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_inject_auth_entries_empty_auth_returns_error() { + let mut envelope = create_invoke_host_function_envelope(vec![]); + let signed_user_auth = create_address_auth_entry(); + + let result = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth.clone()); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, TransactionError::ValidationError(_))); + assert!(err.to_string().contains("must contain auth entries")); + } + + #[test] + fn test_inject_auth_entries_replaces_first_entry() { + let original_auth = create_address_auth_entry(); + let mut envelope = create_invoke_host_function_envelope(vec![original_auth]); + + let signed_user_auth = create_address_auth_entry(); + let result = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth); + + assert!(result.is_ok()); + let auth_entries = result.unwrap(); + assert_eq!(auth_entries.len(), 1); + } + + #[test] + fn test_inject_auth_entries_converts_relayer_to_source_account() { + let user_auth = create_address_auth_entry(); + let relayer_auth = create_address_auth_entry(); + let mut envelope = create_invoke_host_function_envelope(vec![user_auth, relayer_auth]); + + let signed_user_auth = create_address_auth_entry(); + let result = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth); + + assert!(result.is_ok()); + let auth_entries = result.unwrap(); + assert_eq!(auth_entries.len(), 2); + + // Second entry should have SourceAccount credentials + match &auth_entries[1].credentials { + SorobanCredentials::SourceAccount => {} // expected + _ => panic!("Expected SourceAccount credentials for relayer auth entry"), + } + } + + // ==================== build_simulation_envelope tests ==================== + + #[test] + fn test_build_simulation_envelope_v0_returns_error() { + let envelope = create_v0_envelope(); + let auth_entries = vec![create_address_auth_entry()]; + + let result = build_simulation_envelope(&envelope, &auth_entries); + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!(msg.contains("Expected V1 transaction envelope")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_build_simulation_envelope_fee_bump_returns_error() { + let envelope = create_fee_bump_envelope(); + let auth_entries = vec![create_address_auth_entry()]; + + let result = build_simulation_envelope(&envelope, &auth_entries); + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!(msg.contains("Expected V1 transaction envelope")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_build_simulation_envelope_no_operations_returns_error() { + let envelope = create_minimal_v1_envelope(); + let auth_entries = vec![create_address_auth_entry()]; + + let result = build_simulation_envelope(&envelope, &auth_entries); + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!(msg.contains("Transaction has no operations")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_build_simulation_envelope_non_invoke_host_function_returns_error() { + use soroban_rs::xdr::{Asset, PaymentOp}; + + let payment_op = Operation { + source_account: None, + body: OperationBody::Payment(PaymentOp { + destination: MuxedAccount::Ed25519(Uint256([0u8; 32])), + asset: Asset::Native, + amount: 1000, + }), + }; + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])), + fee: 100, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![payment_op].try_into().unwrap(), + ext: TransactionExt::V0, + }; + + let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }); + + let auth_entries = vec![create_address_auth_entry()]; + let result = build_simulation_envelope(&envelope, &auth_entries); + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!(msg.contains("First operation is not InvokeHostFunction")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_build_simulation_envelope_success() { + let original_auth = create_address_auth_entry(); + let envelope = create_invoke_host_function_envelope(vec![original_auth]); + + let new_auth = create_source_account_auth_entry(); + let result = build_simulation_envelope(&envelope, &vec![new_auth]); + + assert!(result.is_ok()); + + // Verify the simulation envelope has the new auth entries + let sim_envelope = result.unwrap(); + if let TransactionEnvelope::Tx(v1) = sim_envelope { + assert_eq!(v1.signatures.len(), 0); // Simulation envelope should have no signatures + assert_eq!(v1.tx.operations.len(), 1); + } else { + panic!("Expected Tx envelope"); + } + } + + #[test] + fn test_build_simulation_envelope_preserves_transaction_fields() { + let original_auth = create_address_auth_entry(); + let mut envelope = create_invoke_host_function_envelope(vec![original_auth]); + + // Modify some fields to verify they're preserved + if let TransactionEnvelope::Tx(ref mut v1) = envelope { + v1.tx.fee = 500; + v1.tx.seq_num = SequenceNumber(42); + } + + let new_auth = create_source_account_auth_entry(); + let result = build_simulation_envelope(&envelope, &vec![new_auth]); + + assert!(result.is_ok()); + let sim_envelope = result.unwrap(); + + if let TransactionEnvelope::Tx(v1) = sim_envelope { + assert_eq!(v1.tx.fee, 500); + assert_eq!(v1.tx.seq_num.0, 42); + } else { + panic!("Expected Tx envelope"); + } + } + + // ==================== Fee validation helper tests ==================== + + #[test] + fn test_extract_i128_from_scval_positive() { + use soroban_rs::xdr::Int128Parts; + let val = ScVal::I128(Int128Parts { hi: 0, lo: 1000000 }); + let result = extract_i128_from_scval(&val); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 1000000i128); + } + + #[test] + fn test_extract_i128_from_scval_large() { + use soroban_rs::xdr::Int128Parts; + // Test a large value that uses both hi and lo parts + let val = ScVal::I128(Int128Parts { hi: 1, lo: 0 }); + let result = extract_i128_from_scval(&val); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 1i128 << 64); + } + + #[test] + fn test_extract_i128_from_scval_negative() { + use soroban_rs::xdr::Int128Parts; + let val = ScVal::I128(Int128Parts { + hi: -1, + lo: u64::MAX, + }); + let result = extract_i128_from_scval(&val); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), -1i128); + } + + #[test] + fn test_extract_i128_from_scval_wrong_type() { + let val = ScVal::U32(42); + let result = extract_i128_from_scval(&val); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Expected I128")); + } + + #[test] + fn test_extract_contract_address_from_scval_valid() { + let contract_id = ContractId(Hash([1u8; 32])); + let val = ScVal::Address(ScAddress::Contract(contract_id)); + let result = extract_contract_address_from_scval(&val); + assert!(result.is_ok()); + // The result should be a valid C... address + assert!(result.unwrap().starts_with('C')); + } + + #[test] + fn test_extract_contract_address_from_scval_account_address() { + // Account addresses (G...) should return error + let account_id = soroban_rs::xdr::AccountId( + soroban_rs::xdr::PublicKey::PublicKeyTypeEd25519(Uint256([0u8; 32])), + ); + let val = ScVal::Address(ScAddress::Account(account_id)); + let result = extract_contract_address_from_scval(&val); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Expected contract address")); + } + + #[test] + fn test_extract_contract_address_from_scval_wrong_type() { + let val = ScVal::U32(42); + let result = extract_contract_address_from_scval(&val); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Expected Address")); + } + + #[test] + fn test_extract_fee_params_from_auth_valid() { + use soroban_rs::xdr::{Int128Parts, InvokeContractArgs, ScSymbol}; + + // Create auth entry with proper FeeForwarder args: + // fee_token, max_fee_amount, expiration_ledger, target_contract, target_fn, target_args + let fee_token = ScVal::Address(ScAddress::Contract(ContractId(Hash([1u8; 32])))); + let max_fee_amount = ScVal::I128(Int128Parts { hi: 0, lo: 5000000 }); // 5 tokens + let expiration_ledger = ScVal::U32(100000); + let target_contract = ScVal::Address(ScAddress::Contract(ContractId(Hash([2u8; 32])))); + let target_fn = ScVal::Symbol(ScSymbol("transfer".try_into().unwrap())); + let target_args = ScVal::Vec(None); + + let auth = SorobanAuthorizationEntry { + credentials: SorobanCredentials::SourceAccount, + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("forward".try_into().unwrap()), + args: vec![ + fee_token, + max_fee_amount, + expiration_ledger, + target_contract, + target_fn, + target_args, + ] + .try_into() + .unwrap(), + }), + sub_invocations: VecM::default(), + }, + }; + + let result = extract_fee_params_from_auth(&auth); + assert!(result.is_ok()); + + let (fee_token_addr, max_fee) = result.unwrap(); + assert!(fee_token_addr.starts_with('C')); // Contract address + assert_eq!(max_fee, 5000000i128); + } + + #[test] + fn test_extract_fee_params_from_auth_insufficient_args() { + use soroban_rs::xdr::{InvokeContractArgs, ScSymbol}; + + // Create auth entry with only 1 argument (not enough) + let auth = SorobanAuthorizationEntry { + credentials: SorobanCredentials::SourceAccount, + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("forward".try_into().unwrap()), + args: vec![ScVal::U32(42)].try_into().unwrap(), + }), + sub_invocations: VecM::default(), + }, + }; + + let result = extract_fee_params_from_auth(&auth); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("at least 2 arguments")); + } +} + +#[cfg(test)] +mod integration_tests { + use super::*; + use crate::models::TransactionInput; + use crate::repositories::MockTransactionCounterTrait; + use crate::services::provider::MockStellarProviderTrait; + use crate::services::stellar_dex::MockStellarDexServiceTrait; + use soroban_rs::stellar_rpc_client::SimulateTransactionResponse; + use soroban_rs::xdr::{ + ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo, + MuxedAccount, Operation, Preconditions, ScAddress, ScSymbol, ScVal, SequenceNumber, + SorobanAddressCredentials, SorobanAuthorizationEntry, SorobanAuthorizedFunction, + SorobanAuthorizedInvocation, SorobanCredentials, SorobanTransactionData, Transaction, + TransactionExt, TransactionV1Envelope, Uint256, VecM, + }; + use std::future::ready; + + /// Create a mock DEX service for tests (not used when policy is None) + fn create_mock_dex_service() -> MockStellarDexServiceTrait { + MockStellarDexServiceTrait::new() + } + + fn create_gas_abstraction_envelope() -> TransactionEnvelope { + let user_auth = SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: ScAddress::Contract(ContractId(Hash([1u8; 32]))), + nonce: 12345, + signature_expiration_ledger: 1000, + signature: ScVal::Void, + }), + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("forward".try_into().unwrap()), + args: VecM::default(), + }), + sub_invocations: VecM::default(), + }, + }; + + let relayer_auth = SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: ScAddress::Contract(ContractId(Hash([2u8; 32]))), + nonce: 67890, + signature_expiration_ledger: 1000, + signature: ScVal::Void, + }), + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("collect".try_into().unwrap()), + args: VecM::default(), + }), + sub_invocations: VecM::default(), + }, + }; + + let invoke_op = InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("forward".try_into().unwrap()), + args: VecM::default(), + }), + auth: vec![user_auth, relayer_auth].try_into().unwrap(), + }; + + let operation = Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(invoke_op), + }; + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])), + fee: 100, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![operation].try_into().unwrap(), + ext: TransactionExt::V0, + }; + + TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }) + } + + fn create_valid_soroban_tx_data_xdr() -> String { + use soroban_rs::xdr::SorobanTransactionDataExt; + + let tx_data = SorobanTransactionData { + ext: SorobanTransactionDataExt::V0, + resources: soroban_rs::xdr::SorobanResources { + footprint: soroban_rs::xdr::LedgerFootprint { + read_only: VecM::default(), + read_write: VecM::default(), + }, + instructions: 1000, + disk_read_bytes: 500, + write_bytes: 200, + }, + resource_fee: 100, + }; + tx_data.to_xdr_base64(Limits::none()).unwrap() + } + + #[tokio::test] + async fn test_process_soroban_gas_abstraction_invalid_input_type() { + let counter = MockTransactionCounterTrait::new(); + let provider = MockStellarProviderTrait::new(); + + let stellar_data = StellarTransactionData { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + network_passphrase: "Test SDF Network ; September 2015".to_string(), + fee: Some(100), + sequence_number: None, + transaction_input: TransactionInput::Operations(vec![]), // Wrong type + memo: None, + valid_until: None, + signatures: vec![], + hash: None, + simulation_transaction_data: None, + signed_envelope_xdr: None, + transaction_result_xdr: None, + }; + + let dex_service = create_mock_dex_service(); + + let result = process_soroban_gas_abstraction( + &counter, + "relayer-1", + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + &provider, + stellar_data, + None, // No policy - skip fee validation + &dex_service, + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!(msg.contains("Expected SorobanGasAbstraction")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[tokio::test] + async fn test_process_soroban_gas_abstraction_invalid_xdr() { + let counter = MockTransactionCounterTrait::new(); + let provider = MockStellarProviderTrait::new(); + let dex_service = create_mock_dex_service(); + + let stellar_data = StellarTransactionData { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + network_passphrase: "Test SDF Network ; September 2015".to_string(), + fee: Some(100), + sequence_number: None, + transaction_input: TransactionInput::SorobanGasAbstraction { + xdr: "invalid-xdr".to_string(), + signed_auth_entry: "also-invalid".to_string(), + }, + memo: None, + valid_until: None, + signatures: vec![], + hash: None, + simulation_transaction_data: None, + signed_envelope_xdr: None, + transaction_result_xdr: None, + }; + + let result = process_soroban_gas_abstraction( + &counter, + "relayer-1", + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + &provider, + stellar_data, + None, // No policy - skip fee validation + &dex_service, + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!(msg.contains("Failed to parse transaction XDR")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[tokio::test] + async fn test_process_soroban_gas_abstraction_invalid_auth_entry() { + let counter = MockTransactionCounterTrait::new(); + let provider = MockStellarProviderTrait::new(); + let dex_service = create_mock_dex_service(); + + let envelope = create_gas_abstraction_envelope(); + let xdr = envelope.to_xdr_base64(Limits::none()).unwrap(); + + let stellar_data = StellarTransactionData { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + network_passphrase: "Test SDF Network ; September 2015".to_string(), + fee: Some(100), + sequence_number: None, + transaction_input: TransactionInput::SorobanGasAbstraction { + xdr, + signed_auth_entry: "invalid-auth-entry".to_string(), + }, + memo: None, + valid_until: None, + signatures: vec![], + hash: None, + simulation_transaction_data: None, + signed_envelope_xdr: None, + transaction_result_xdr: None, + }; + + let result = process_soroban_gas_abstraction( + &counter, + "relayer-1", + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + &provider, + stellar_data, + None, // No policy - skip fee validation + &dex_service, + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!( + msg.contains("Failed to deserialize") || msg.contains("XdrError"), + "Unexpected error message: {}", + msg + ); + } + other => panic!("Expected ValidationError, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_process_soroban_gas_abstraction_simulation_error() { + let mut counter = MockTransactionCounterTrait::new(); + counter + .expect_get_and_increment() + .returning(|_, _| Box::pin(ready(Ok(42u64)))); + + let mut provider = MockStellarProviderTrait::new(); + provider + .expect_simulate_transaction_envelope() + .returning(|_| { + Box::pin(ready(Ok(SimulateTransactionResponse { + error: Some("Simulation failed: insufficient resources".to_string()), + min_resource_fee: 0, + transaction_data: String::new(), + ..Default::default() + }))) + }); + + let dex_service = create_mock_dex_service(); + + let envelope = create_gas_abstraction_envelope(); + let xdr = envelope.to_xdr_base64(Limits::none()).unwrap(); + + // Create a valid signed auth entry + let signed_auth = SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: ScAddress::Contract(ContractId(Hash([1u8; 32]))), + nonce: 12345, + signature_expiration_ledger: 1000, + signature: ScVal::Void, + }), + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("forward".try_into().unwrap()), + args: VecM::default(), + }), + sub_invocations: VecM::default(), + }, + }; + let signed_auth_xdr = signed_auth.to_xdr_base64(Limits::none()).unwrap(); + + let stellar_data = StellarTransactionData { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + network_passphrase: "Test SDF Network ; September 2015".to_string(), + fee: Some(100), + sequence_number: None, + transaction_input: TransactionInput::SorobanGasAbstraction { + xdr, + signed_auth_entry: signed_auth_xdr, + }, + memo: None, + valid_until: None, + signatures: vec![], + hash: None, + simulation_transaction_data: None, + signed_envelope_xdr: None, + transaction_result_xdr: None, + }; + + let result = process_soroban_gas_abstraction( + &counter, + "relayer-1", + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + &provider, + stellar_data, + None, // No policy - skip fee validation + &dex_service, + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::UnexpectedError(msg) => { + assert!(msg.contains("Simulation failed")); + } + other => panic!("Expected UnexpectedError, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_process_soroban_gas_abstraction_success() { + let mut counter = MockTransactionCounterTrait::new(); + counter + .expect_get_and_increment() + .returning(|_, _| Box::pin(ready(Ok(42u64)))); + + let mut provider = MockStellarProviderTrait::new(); + provider + .expect_simulate_transaction_envelope() + .returning(|_| { + Box::pin(ready(Ok(SimulateTransactionResponse { + error: None, + min_resource_fee: 1000, + transaction_data: create_valid_soroban_tx_data_xdr(), + ..Default::default() + }))) + }); + + let dex_service = create_mock_dex_service(); + + let envelope = create_gas_abstraction_envelope(); + let xdr = envelope.to_xdr_base64(Limits::none()).unwrap(); + + let signed_auth = SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: ScAddress::Contract(ContractId(Hash([1u8; 32]))), + nonce: 12345, + signature_expiration_ledger: 1000, + signature: ScVal::Void, + }), + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("forward".try_into().unwrap()), + args: VecM::default(), + }), + sub_invocations: VecM::default(), + }, + }; + let signed_auth_xdr = signed_auth.to_xdr_base64(Limits::none()).unwrap(); + + let stellar_data = StellarTransactionData { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + network_passphrase: "Test SDF Network ; September 2015".to_string(), + fee: Some(100), + sequence_number: None, + transaction_input: TransactionInput::SorobanGasAbstraction { + xdr, + signed_auth_entry: signed_auth_xdr, + }, + memo: None, + valid_until: None, + signatures: vec![], + hash: None, + simulation_transaction_data: None, + signed_envelope_xdr: None, + transaction_result_xdr: None, + }; + + let result = process_soroban_gas_abstraction( + &counter, + "relayer-1", + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + &provider, + stellar_data, + None, // No policy - skip fee validation + &dex_service, + ) + .await; + + assert!(result.is_ok()); + let prepared = result.unwrap(); + + assert_eq!(prepared.sequence_number, Some(42)); + match prepared.transaction_input { + TransactionInput::UnsignedXdr(_) => {} // Expected + _ => panic!("Expected UnsignedXdr transaction input"), + } + } +} + +#[cfg(test)] +mod validate_gas_abstraction_fee_tests { + use super::*; + use crate::models::{ + RelayerStellarPolicy, StellarAllowedTokensPolicy, StellarAllowedTokensSwapConfig, + }; + use crate::services::provider::MockStellarProviderTrait; + use crate::services::stellar_dex::{MockStellarDexServiceTrait, StellarQuoteResponse}; + use soroban_rs::stellar_rpc_client::SimulateTransactionResponse; + use soroban_rs::xdr::{ + ContractId, Hash, HostFunction, Int128Parts, InvokeContractArgs, InvokeHostFunctionOp, + Memo, MuxedAccount, Operation, OperationBody, Preconditions, ScAddress, ScSymbol, ScVal, + SequenceNumber, SorobanAddressCredentials, SorobanAuthorizationEntry, + SorobanAuthorizedFunction, SorobanAuthorizedInvocation, SorobanCredentials, + SorobanTransactionData, SorobanTransactionDataExt, Transaction, TransactionExt, + TransactionV1Envelope, Uint256, VecM, + }; + use std::future::ready; + + // Helper to get the contract address string from a 32-byte hash + fn get_contract_address_from_hash(hash: [u8; 32]) -> String { + let strkey = stellar_strkey::Contract(hash); + strkey.to_string() + } + + /// Create a valid FeeForwarder auth entry with specified fee token and max fee amount + fn create_fee_forwarder_auth( + fee_token_hash: [u8; 32], + max_fee_amount: i128, + ) -> SorobanAuthorizationEntry { + let fee_token = ScVal::Address(ScAddress::Contract(ContractId(Hash(fee_token_hash)))); + let max_fee = ScVal::I128(Int128Parts { + hi: (max_fee_amount >> 64) as i64, + lo: max_fee_amount as u64, + }); + let expiration_ledger = ScVal::U32(100000); + let target_contract = ScVal::Address(ScAddress::Contract(ContractId(Hash([2u8; 32])))); + let target_fn = ScVal::Symbol(ScSymbol("transfer".try_into().unwrap())); + let target_args = ScVal::Vec(None); + + SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: ScAddress::Contract(ContractId(Hash([1u8; 32]))), + nonce: 12345, + signature_expiration_ledger: 1000, + signature: ScVal::Void, + }), + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("forward".try_into().unwrap()), + args: vec![ + fee_token, + max_fee, + expiration_ledger, + target_contract, + target_fn, + target_args, + ] + .try_into() + .unwrap(), + }), + sub_invocations: VecM::default(), + }, + } + } + + /// Create a basic gas abstraction transaction envelope + fn create_test_envelope() -> TransactionEnvelope { + let invoke_op = InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("forward".try_into().unwrap()), + args: VecM::default(), + }), + auth: VecM::default(), + }; + + let operation = Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(invoke_op), + }; + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])), + fee: 100, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![operation].try_into().unwrap(), + ext: TransactionExt::V0, + }; + + TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }) + } + + /// Create a valid soroban transaction data XDR for simulation response + fn create_valid_soroban_tx_data_xdr() -> String { + let tx_data = SorobanTransactionData { + ext: SorobanTransactionDataExt::V0, + resources: soroban_rs::xdr::SorobanResources { + footprint: soroban_rs::xdr::LedgerFootprint { + read_only: VecM::default(), + read_write: VecM::default(), + }, + instructions: 1000, + disk_read_bytes: 500, + write_bytes: 200, + }, + resource_fee: 100, + }; + tx_data.to_xdr_base64(Limits::none()).unwrap() + } + + /// Create a policy with a specific allowed token + fn create_policy_with_token( + token_address: &str, + max_allowed_fee: Option, + ) -> RelayerStellarPolicy { + RelayerStellarPolicy { + min_balance: None, + max_fee: None, + timeout_seconds: None, + concurrent_transactions: None, + allowed_tokens: Some(vec![StellarAllowedTokensPolicy { + asset: token_address.to_string(), + metadata: None, + max_allowed_fee, + swap_config: Some(StellarAllowedTokensSwapConfig { + slippage_percentage: Some(1.0), + min_amount: None, + max_amount: None, + retain_min_amount: None, + }), + }]), + fee_payment_strategy: None, + slippage_percentage: Some(1.0), + fee_margin_percentage: Some(10.0), + swap_config: None, + } + } + + #[tokio::test] + async fn test_validate_gas_abstraction_fee_disallowed_token() { + // Setup: provider and dex_service (won't be called for disallowed token) + let provider = MockStellarProviderTrait::new(); + let dex_service = MockStellarDexServiceTrait::new(); + + // Create envelope and auth entry + let envelope = create_test_envelope(); + let fee_token_hash = [5u8; 32]; + let signed_auth = create_fee_forwarder_auth(fee_token_hash, 10_000_000); + + // Create a policy that does NOT include our fee token + let different_token = get_contract_address_from_hash([9u8; 32]); + let policy = create_policy_with_token(&different_token, Some(50_000_000)); + + // Execute + let result = validate_gas_abstraction_fee( + &envelope, + &signed_auth, + &policy, + None, + &provider, + &dex_service, + ) + .await; + + // Assert + assert!(result.is_err()); + let err = result.unwrap_err(); + match err { + TransactionError::ValidationError(msg) => { + assert!( + msg.contains("Fee token validation failed"), + "Expected 'Fee token validation failed' in error message, got: {}", + msg + ); + } + other => panic!("Expected ValidationError, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_validate_gas_abstraction_fee_exceeds_max_allowed() { + // Setup: provider and dex_service (won't be called if max_allowed_fee check fails first) + let provider = MockStellarProviderTrait::new(); + let dex_service = MockStellarDexServiceTrait::new(); + + // Create envelope and auth entry + let envelope = create_test_envelope(); + let fee_token_hash = [5u8; 32]; + let fee_token_address = get_contract_address_from_hash(fee_token_hash); + + // User is trying to authorize 100 tokens, but policy only allows max 50 + let signed_auth = create_fee_forwarder_auth(fee_token_hash, 100_000_000); + + // Policy allows this token but with max_allowed_fee of 50 + let policy = create_policy_with_token(&fee_token_address, Some(50_000_000)); + + // Execute + let result = validate_gas_abstraction_fee( + &envelope, + &signed_auth, + &policy, + None, + &provider, + &dex_service, + ) + .await; + + // Assert + assert!(result.is_err()); + let err = result.unwrap_err(); + match err { + TransactionError::ValidationError(msg) => { + assert!( + msg.contains("Max fee validation failed"), + "Expected 'Max fee validation failed' in error message, got: {}", + msg + ); + } + other => panic!("Expected ValidationError, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_validate_gas_abstraction_fee_insufficient_max_fee() { + // Setup: simulate transaction returns a resource fee, DEX converts it + let mut provider = MockStellarProviderTrait::new(); + provider + .expect_simulate_transaction_envelope() + .returning(|_| { + Box::pin(ready(Ok(SimulateTransactionResponse { + error: None, + min_resource_fee: 10000, // 10000 stroops resource fee + transaction_data: create_valid_soroban_tx_data_xdr(), + ..Default::default() + }))) + }); + + let mut dex_service = MockStellarDexServiceTrait::new(); + // DEX returns that required fee in token is 5_000_000 (5 tokens) + // convert_xlm_fee_to_token uses get_xlm_to_token_quote + dex_service + .expect_get_xlm_to_token_quote() + .returning(|_, _, _, _| { + Box::pin(ready(Ok(StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: "token".to_string(), + in_amount: 10000, // Input XLM + out_amount: 5_000_000, // Required fee in token + price_impact_pct: 0.1, + slippage_bps: 100, + path: None, + }))) + }); + + // Create envelope and auth entry + let envelope = create_test_envelope(); + let fee_token_hash = [5u8; 32]; + let fee_token_address = get_contract_address_from_hash(fee_token_hash); + + // User authorizes only 1_000_000 (1 token), but 5 tokens are needed + let signed_auth = create_fee_forwarder_auth(fee_token_hash, 1_000_000); + + // Policy allows token with high max_allowed_fee (no issue there) + let policy = create_policy_with_token(&fee_token_address, Some(100_000_000)); + + // Execute + let result = validate_gas_abstraction_fee( + &envelope, + &signed_auth, + &policy, + None, + &provider, + &dex_service, + ) + .await; + + // Assert + assert!(result.is_err()); + let err = result.unwrap_err(); + match err { + TransactionError::ValidationError(msg) => { + assert!( + msg.contains("Insufficient max_fee_amount"), + "Expected 'Insufficient max_fee_amount' in error message, got: {}", + msg + ); + assert!( + msg.contains("relayer liquidity loss"), + "Expected 'relayer liquidity loss' warning in error message, got: {}", + msg + ); + } + other => panic!("Expected ValidationError, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_validate_gas_abstraction_fee_success() { + // Setup: simulate transaction returns a resource fee, DEX converts it + let mut provider = MockStellarProviderTrait::new(); + provider + .expect_simulate_transaction_envelope() + .returning(|_| { + Box::pin(ready(Ok(SimulateTransactionResponse { + error: None, + min_resource_fee: 10000, // 10000 stroops resource fee + transaction_data: create_valid_soroban_tx_data_xdr(), + ..Default::default() + }))) + }); + + let mut dex_service = MockStellarDexServiceTrait::new(); + // DEX returns that required fee in token is 5_000_000 (5 tokens) + // convert_xlm_fee_to_token uses get_xlm_to_token_quote + dex_service + .expect_get_xlm_to_token_quote() + .returning(|_, _, _, _| { + Box::pin(ready(Ok(StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: "token".to_string(), + in_amount: 10000, // Input XLM + out_amount: 5_000_000, // Required fee in token + price_impact_pct: 0.1, + slippage_bps: 100, + path: None, + }))) + }); + + // Create envelope and auth entry + let envelope = create_test_envelope(); + let fee_token_hash = [5u8; 32]; + let fee_token_address = get_contract_address_from_hash(fee_token_hash); + + // User authorizes 10_000_000 (10 tokens), which exceeds the required 5 tokens + let signed_auth = create_fee_forwarder_auth(fee_token_hash, 10_000_000); + + // Policy allows token with max_allowed_fee of 20 tokens + let policy = create_policy_with_token(&fee_token_address, Some(20_000_000)); + + // Execute + let result = validate_gas_abstraction_fee( + &envelope, + &signed_auth, + &policy, + None, + &provider, + &dex_service, + ) + .await; + + // Assert + assert!( + result.is_ok(), + "Expected validation to pass, got error: {:?}", + result.unwrap_err() + ); + } + + #[tokio::test] + async fn test_validate_gas_abstraction_fee_negative_amount_rejected() { + // Setup + let provider = MockStellarProviderTrait::new(); + let dex_service = MockStellarDexServiceTrait::new(); + + // Create envelope and auth entry with negative max_fee_amount + let envelope = create_test_envelope(); + let fee_token_hash = [5u8; 32]; + let fee_token_address = get_contract_address_from_hash(fee_token_hash); + + // User provides negative max_fee_amount + let signed_auth = create_fee_forwarder_auth(fee_token_hash, -1000); + + // Policy allows token + let policy = create_policy_with_token(&fee_token_address, Some(100_000_000)); + + // Execute + let result = validate_gas_abstraction_fee( + &envelope, + &signed_auth, + &policy, + None, + &provider, + &dex_service, + ) + .await; + + // Assert + assert!(result.is_err()); + let err = result.unwrap_err(); + match err { + TransactionError::ValidationError(msg) => { + assert!( + msg.contains("must be positive"), + "Expected 'must be positive' in error message, got: {msg}", + ); + } + other => panic!("Expected ValidationError, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_validate_gas_abstraction_fee_simulation_error() { + // Setup: simulation returns an error with zero resource fee + let mut provider = MockStellarProviderTrait::new(); + provider + .expect_simulate_transaction_envelope() + .returning(|_| { + Box::pin(ready(Ok(SimulateTransactionResponse { + error: Some("Simulation failed: contract not found".to_string()), + min_resource_fee: 0, + transaction_data: String::new(), + ..Default::default() + }))) + }); + + let dex_service = MockStellarDexServiceTrait::new(); + + // Create envelope and auth entry + let envelope = create_test_envelope(); + let fee_token_hash = [5u8; 32]; + let fee_token_address = get_contract_address_from_hash(fee_token_hash); + let signed_auth = create_fee_forwarder_auth(fee_token_hash, 10_000_000); + + // Policy allows token + let policy = create_policy_with_token(&fee_token_address, Some(100_000_000)); + + // Execute + let result = validate_gas_abstraction_fee( + &envelope, + &signed_auth, + &policy, + None, + &provider, + &dex_service, + ) + .await; + + // Assert: When simulation fails with zero resource fee, it returns ValidationError + assert!(result.is_err()); + let err = result.unwrap_err(); + match err { + TransactionError::ValidationError(msg) => { + assert!( + msg.contains("Simulation failed"), + "Expected 'Simulation failed' in error message, got: {}", + msg + ); + } + other => panic!("Expected ValidationError, got: {:?}", other), + } + } +} diff --git a/src/domain/transaction/stellar/prepare/unsigned_xdr.rs b/src/domain/transaction/stellar/prepare/unsigned_xdr.rs index d9bb7845b..0c16a1354 100644 --- a/src/domain/transaction/stellar/prepare/unsigned_xdr.rs +++ b/src/domain/transaction/stellar/prepare/unsigned_xdr.rs @@ -2,7 +2,10 @@ //! It includes XDR parsing, validation, sequence updating, and fee updating. use eyre::Result; -use soroban_rs::xdr::{Limits, OperationBody, ReadXdr, TransactionEnvelope, WriteXdr}; +use soroban_rs::xdr::{ + HostFunction, Limits, OperationBody, ReadXdr, ScAddress, ScVal, SorobanTransactionData, + TransactionEnvelope, TransactionExt, WriteXdr, +}; use tracing::debug; use crate::{ @@ -132,13 +135,27 @@ fn is_valid_swap_transaction( return Ok(false); } - // Check if the operation is PathPaymentStrictSend let op = &operations[0]; - let path_payment = match &op.body { - OperationBody::PathPaymentStrictSend(path_payment_op) => path_payment_op, - _ => return Ok(false), - }; + match &op.body { + // OrderBook swap: PathPaymentStrictSend (classic assets) + OperationBody::PathPaymentStrictSend(path_payment) => { + is_valid_orderbook_swap(path_payment, relayer_address, policy) + } + // Soroswap swap: InvokeHostFunction calling swap_exact_tokens_for_tokens + OperationBody::InvokeHostFunction(invoke_op) => { + is_valid_soroswap_swap(&invoke_op.host_function, relayer_address) + } + _ => Ok(false), + } +} + +/// Validate an OrderBook swap transaction (PathPaymentStrictSend) +fn is_valid_orderbook_swap( + path_payment: &soroban_rs::xdr::PathPaymentStrictSendOp, + relayer_address: &str, + policy: &RelayerStellarPolicy, +) -> Result { // Validate source asset is an allowed token let source_asset_id = asset_to_asset_id(&path_payment.send_asset).map_err(|e| { TransactionError::ValidationError(format!( @@ -179,7 +196,46 @@ fn is_valid_swap_transaction( return Ok(false); } - // All validations passed - this is a valid swap transaction + Ok(true) +} + +/// Validate a Soroswap swap transaction (InvokeHostFunction calling swap_exact_tokens_for_tokens) +fn is_valid_soroswap_swap( + host_function: &HostFunction, + relayer_address: &str, +) -> Result { + let invoke_args = match host_function { + HostFunction::InvokeContract(args) => args, + _ => return Ok(false), + }; + + // Must be calling swap_exact_tokens_for_tokens + if invoke_args.function_name.to_string() != "swap_exact_tokens_for_tokens" { + return Ok(false); + } + + // swap_exact_tokens_for_tokens(amount_in, amount_out_min, path, to, deadline) + // args[3] is the `to` address — must be the relayer + if invoke_args.args.len() != 5 { + return Ok(false); + } + + let to_address = match &invoke_args.args[3] { + ScVal::Address(ScAddress::Account(account_id)) => { + use soroban_rs::xdr::PublicKey; + match &account_id.0 { + PublicKey::PublicKeyTypeEd25519(key) => { + stellar_strkey::ed25519::PublicKey(key.0).to_string() + } + } + } + _ => return Ok(false), + }; + + if to_address != relayer_address { + return Ok(false); + } + Ok(true) } @@ -317,7 +373,81 @@ where let stellar_data_with_sim = match simulate_if_needed(&envelope, provider).await? { Some(sim_resp) => { debug!("Applying simulation results to unsigned XDR transaction"); - // Get operation count from the envelope + + // Parse SorobanTransactionData from simulation response + let soroban_tx_data = + SorobanTransactionData::from_xdr_base64(&sim_resp.transaction_data, Limits::none()) + .map_err(|e| { + TransactionError::ValidationError(format!( + "Failed to parse simulation transaction_data: {e}" + )) + })?; + + // Apply simulation results to the envelope: + // 1. Set TransactionExt::V1 with SorobanTransactionData (resource footprint) + // 2. Set auth entries on InvokeHostFunction operations + // 3. Update fee based on simulation + match &mut envelope { + TransactionEnvelope::Tx(ref mut env) => { + // Apply SorobanTransactionData (resource footprint, read/write sets) + env.tx.ext = TransactionExt::V1(soroban_tx_data); + + // Apply auth entries from simulation results + if let Ok(results) = sim_resp.results() { + if let Some(result) = results.first() { + if !result.auth.is_empty() { + // Find the InvokeHostFunction operation and set auth entries + for op in env.tx.operations.iter_mut() { + if let OperationBody::InvokeHostFunction(ref mut invoke_op) = + op.body + { + invoke_op.auth = + result.auth.clone().try_into().map_err(|_| { + TransactionError::ValidationError( + "Failed to set simulation auth entries" + .to_string(), + ) + })?; + debug!( + "Applied {} auth entries from simulation", + result.auth.len() + ); + } + } + } + } + } + + // Update fee: inclusion fee + resource fee from simulation + let op_count = env.tx.operations.len() as u64; + let inclusion_fee = op_count * STELLAR_DEFAULT_TRANSACTION_FEE as u64; + let resource_fee = sim_resp.min_resource_fee; + let updated_fee = u32::try_from(inclusion_fee + resource_fee) + .unwrap_or(u32::MAX) + .max(STELLAR_DEFAULT_TRANSACTION_FEE); + env.tx.fee = updated_fee; + + debug!( + "Applied simulation: fee={}, resource_fee={}", + updated_fee, resource_fee + ); + } + _ => { + return Err(TransactionError::ValidationError( + "Expected V1 transaction envelope for Soroban simulation".to_string(), + )); + } + } + + // Re-serialize the updated envelope with simulation data applied + let updated_xdr = envelope.to_xdr_base64(Limits::none()).map_err(|e| { + TransactionError::ValidationError(format!( + "Failed to serialize envelope after simulation: {e}" + )) + })?; + + // Update stellar data with new XDR and simulation metadata + stellar_data.transaction_input = TransactionInput::UnsignedXdr(updated_xdr); let op_count = extract_operations(&envelope)?.len() as u64; stellar_data .with_simulation_data(sim_resp, op_count) @@ -1026,6 +1156,526 @@ mod tests { assert!(result.is_ok()); assert!(!result.unwrap()); // Should return false for wrong destination } + + // ===== Soroswap Swap Validation Tests ===== + + /// Helper: build a V1 envelope with a single InvokeHostFunction operation + fn create_soroswap_invoke_envelope( + source: &str, + function_name: &str, + args: Vec, + ) -> TransactionEnvelope { + use soroban_rs::xdr::{ContractId, Hash, InvokeContractArgs, InvokeHostFunctionOp, VecM}; + + let invoke_args = InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: function_name.as_bytes().to_vec().try_into().unwrap(), + args: args.try_into().unwrap(), + }; + + let op = soroban_rs::xdr::Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(invoke_args), + auth: VecM::default(), + }), + }; + + create_v1_envelope(source, vec![op], 100, 1) + } + + /// Helper: build the 5 ScVal args for `swap_exact_tokens_for_tokens` + fn create_soroswap_swap_args(to_address: &str) -> Vec { + use soroban_rs::xdr::{AccountId, Int128Parts, PublicKey, Uint256}; + + let pk = stellar_strkey::ed25519::PublicKey::from_string(to_address).unwrap(); + let to_account = ScVal::Address(ScAddress::Account(AccountId( + PublicKey::PublicKeyTypeEd25519(Uint256(pk.0)), + ))); + + vec![ + ScVal::I128(Int128Parts { hi: 0, lo: 1000000 }), // amount_in + ScVal::I128(Int128Parts { hi: 0, lo: 900000 }), // amount_out_min + ScVal::Vec(Some(Default::default())), // path (empty) + to_account, // to + ScVal::U64(1_000_000_000), // deadline + ] + } + + #[test] + fn test_is_valid_swap_transaction_with_valid_soroswap_swap() { + let relayer_address = TEST_PK; + let args = create_soroswap_swap_args(relayer_address); + let envelope = + create_soroswap_invoke_envelope(relayer_address, "swap_exact_tokens_for_tokens", args); + + let policy = RelayerStellarPolicy::default(); + let result = is_valid_swap_transaction(&envelope, relayer_address, &policy); + assert!(result.is_ok()); + assert!(result.unwrap()); + } + + #[test] + fn test_is_valid_swap_transaction_soroswap_wrong_function_name() { + let relayer_address = TEST_PK; + let args = create_soroswap_swap_args(relayer_address); + let envelope = create_soroswap_invoke_envelope(relayer_address, "transfer", args); + + let policy = RelayerStellarPolicy::default(); + let result = is_valid_swap_transaction(&envelope, relayer_address, &policy); + assert!(result.is_ok()); + assert!(!result.unwrap()); + } + + #[test] + fn test_is_valid_swap_transaction_soroswap_wrong_arg_count() { + use soroban_rs::xdr::Int128Parts; + + let relayer_address = TEST_PK; + // Only 3 args instead of 5 + let args = vec![ + ScVal::I128(Int128Parts { hi: 0, lo: 1000000 }), + ScVal::I128(Int128Parts { hi: 0, lo: 900000 }), + ScVal::U64(1_000_000_000), + ]; + let envelope = + create_soroswap_invoke_envelope(relayer_address, "swap_exact_tokens_for_tokens", args); + + let policy = RelayerStellarPolicy::default(); + let result = is_valid_swap_transaction(&envelope, relayer_address, &policy); + assert!(result.is_ok()); + assert!(!result.unwrap()); + } + + #[test] + fn test_is_valid_swap_transaction_soroswap_non_address_to_arg() { + use soroban_rs::xdr::Int128Parts; + + let relayer_address = TEST_PK; + // args[3] is U64 instead of Address + let args = vec![ + ScVal::I128(Int128Parts { hi: 0, lo: 1000000 }), + ScVal::I128(Int128Parts { hi: 0, lo: 900000 }), + ScVal::Vec(Some(Default::default())), + ScVal::U64(12345), // NOT an address + ScVal::U64(1_000_000_000), + ]; + let envelope = + create_soroswap_invoke_envelope(relayer_address, "swap_exact_tokens_for_tokens", args); + + let policy = RelayerStellarPolicy::default(); + let result = is_valid_swap_transaction(&envelope, relayer_address, &policy); + assert!(result.is_ok()); + assert!(!result.unwrap()); + } + + #[test] + fn test_is_valid_swap_transaction_soroswap_wrong_to_address() { + let relayer_address = TEST_PK; + let args = create_soroswap_swap_args(TEST_PK_2); // different address + let envelope = + create_soroswap_invoke_envelope(relayer_address, "swap_exact_tokens_for_tokens", args); + + let policy = RelayerStellarPolicy::default(); + let result = is_valid_swap_transaction(&envelope, relayer_address, &policy); + assert!(result.is_ok()); + assert!(!result.unwrap()); + } + + // ===== Simulation Path Tests ===== + + /// Helper: create a valid SorobanTransactionData XDR base64 string + fn create_valid_soroban_tx_data_xdr() -> String { + use soroban_rs::xdr::{LedgerFootprint, SorobanResources, SorobanTransactionDataExt, VecM}; + + let data = SorobanTransactionData { + ext: SorobanTransactionDataExt::V0, + resources: SorobanResources { + footprint: LedgerFootprint { + read_only: VecM::default(), + read_write: VecM::default(), + }, + instructions: 1000, + disk_read_bytes: 500, + write_bytes: 200, + }, + resource_fee: 10000, + }; + data.to_xdr_base64(Limits::none()).unwrap() + } + + /// Helper: create a valid SorobanAuthorizationEntry XDR base64 string + fn create_valid_auth_entry_xdr() -> String { + use soroban_rs::xdr::{ + ContractId, Hash, InvokeContractArgs, SorobanAuthorizationEntry, + SorobanAuthorizedFunction, SorobanAuthorizedInvocation, SorobanCredentials, VecM, + }; + + let entry = SorobanAuthorizationEntry { + credentials: SorobanCredentials::SourceAccount, + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: "test".as_bytes().to_vec().try_into().unwrap(), + args: VecM::default(), + }), + sub_invocations: VecM::default(), + }, + }; + entry.to_xdr_base64(Limits::none()).unwrap() + } + + /// Helper: create StellarTransactionData from an envelope + fn create_stellar_data_with_invoke( + source: &str, + envelope: &TransactionEnvelope, + ) -> crate::models::StellarTransactionData { + use crate::models::TransactionInput; + + let xdr = envelope.to_xdr_base64(Limits::none()).unwrap(); + crate::models::StellarTransactionData { + source_account: source.to_string(), + network_passphrase: "Test SDF Network ; September 2015".to_string(), + fee: None, + sequence_number: None, + transaction_input: TransactionInput::UnsignedXdr(xdr), + memo: None, + valid_until: None, + signatures: vec![], + hash: None, + simulation_transaction_data: None, + signed_envelope_xdr: None, + transaction_result_xdr: None, + } + } + + /// Helper: set up default mocks for simulation tests + fn setup_simulation_mocks( + min_resource_fee: u64, + tx_data_xdr: String, + results: Vec, + ) -> ( + MockTransactionCounterTrait, + MockStellarProviderTrait, + MockSigner, + ) { + let mut counter = MockTransactionCounterTrait::new(); + counter + .expect_get_and_increment() + .returning(|_, _| Box::pin(ready(Ok(42)))); + + let mut provider = MockStellarProviderTrait::new(); + provider + .expect_simulate_transaction_envelope() + .returning(move |_| { + let tx_data = tx_data_xdr.clone(); + let res = results.clone(); + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::SimulateTransactionResponse { + min_resource_fee, + transaction_data: tx_data, + results: res, + ..Default::default() + }, + ))) + }); + + let mut signer = MockSigner::new(); + signer.expect_address().returning(|| { + Box::pin(ready(Ok(crate::models::Address::Stellar( + "test-signer-address".to_string(), + )))) + }); + signer.expect_sign_transaction().returning(|_| { + let sig_bytes: Vec = vec![1u8; 64]; + let sig_bytes_m: BytesM<64> = sig_bytes.try_into().unwrap(); + Box::pin(ready(Ok(SignTransactionResponse::Stellar( + crate::domain::SignTransactionResponseStellar { + signature: DecoratedSignature { + hint: SignatureHint([0; 4]), + signature: Signature(sig_bytes_m), + }, + }, + )))) + }); + + (counter, provider, signer) + } + + #[tokio::test] + async fn test_process_unsigned_xdr_with_simulation_and_auth() { + let relayer_address = TEST_PK; + let relayer_id = "test-relayer"; + let tx_data_xdr = create_valid_soroban_tx_data_xdr(); + let auth_xdr = create_valid_auth_entry_xdr(); + let results = vec![ + soroban_rs::stellar_rpc_client::SimulateHostFunctionResultRaw { + auth: vec![auth_xdr], + xdr: ScVal::Void.to_xdr_base64(Limits::none()).unwrap(), + }, + ]; + + let (counter, provider, signer) = setup_simulation_mocks(5000, tx_data_xdr, results); + + let args = create_soroswap_swap_args(relayer_address); + let envelope = + create_soroswap_invoke_envelope(relayer_address, "swap_exact_tokens_for_tokens", args); + let stellar_data = create_stellar_data_with_invoke(relayer_address, &envelope); + + let dex_service = MockStellarDexServiceTrait::new(); + let result = process_unsigned_xdr( + &counter, + relayer_id, + relayer_address, + stellar_data, + &provider, + &signer, + None, + &dex_service, + ) + .await; + + assert!(result.is_ok(), "Expected success, got: {:?}", result); + let updated_data = result.unwrap(); + assert!(updated_data.signed_envelope_xdr.is_some()); + assert!(!updated_data.signatures.is_empty()); + assert!(updated_data.simulation_transaction_data.is_some()); + } + + #[tokio::test] + async fn test_process_unsigned_xdr_with_simulation_no_results() { + let relayer_address = TEST_PK; + let relayer_id = "test-relayer"; + let tx_data_xdr = create_valid_soroban_tx_data_xdr(); + + let (counter, provider, signer) = setup_simulation_mocks(3000, tx_data_xdr, vec![]); + + let args = create_soroswap_swap_args(relayer_address); + let envelope = + create_soroswap_invoke_envelope(relayer_address, "swap_exact_tokens_for_tokens", args); + let stellar_data = create_stellar_data_with_invoke(relayer_address, &envelope); + + let dex_service = MockStellarDexServiceTrait::new(); + let result = process_unsigned_xdr( + &counter, + relayer_id, + relayer_address, + stellar_data, + &provider, + &signer, + None, + &dex_service, + ) + .await; + + assert!(result.is_ok(), "Expected success, got: {:?}", result); + assert!(result.unwrap().simulation_transaction_data.is_some()); + } + + #[tokio::test] + async fn test_process_unsigned_xdr_with_simulation_empty_auth() { + let relayer_address = TEST_PK; + let relayer_id = "test-relayer"; + let tx_data_xdr = create_valid_soroban_tx_data_xdr(); + let results = vec![ + soroban_rs::stellar_rpc_client::SimulateHostFunctionResultRaw { + auth: vec![], // empty auth + xdr: ScVal::Void.to_xdr_base64(Limits::none()).unwrap(), + }, + ]; + + let (counter, provider, signer) = setup_simulation_mocks(5000, tx_data_xdr, results); + + let args = create_soroswap_swap_args(relayer_address); + let envelope = + create_soroswap_invoke_envelope(relayer_address, "swap_exact_tokens_for_tokens", args); + let stellar_data = create_stellar_data_with_invoke(relayer_address, &envelope); + + let dex_service = MockStellarDexServiceTrait::new(); + let result = process_unsigned_xdr( + &counter, + relayer_id, + relayer_address, + stellar_data, + &provider, + &signer, + None, + &dex_service, + ) + .await; + + assert!(result.is_ok(), "Expected success, got: {:?}", result); + } + + #[tokio::test] + async fn test_process_unsigned_xdr_with_simulation_invalid_auth_skipped() { + let relayer_address = TEST_PK; + let relayer_id = "test-relayer"; + let tx_data_xdr = create_valid_soroban_tx_data_xdr(); + // Invalid auth XDR causes results() to fail, which is silently skipped + let results = vec![ + soroban_rs::stellar_rpc_client::SimulateHostFunctionResultRaw { + auth: vec!["invalid-auth-xdr".to_string()], + xdr: ScVal::Void.to_xdr_base64(Limits::none()).unwrap(), + }, + ]; + + let (counter, provider, signer) = setup_simulation_mocks(5000, tx_data_xdr, results); + + let args = create_soroswap_swap_args(relayer_address); + let envelope = + create_soroswap_invoke_envelope(relayer_address, "swap_exact_tokens_for_tokens", args); + let stellar_data = create_stellar_data_with_invoke(relayer_address, &envelope); + + let dex_service = MockStellarDexServiceTrait::new(); + let result = process_unsigned_xdr( + &counter, + relayer_id, + relayer_address, + stellar_data, + &provider, + &signer, + None, + &dex_service, + ) + .await; + + // Invalid auth XDR is silently skipped via `if let Ok(results)` + assert!( + result.is_ok(), + "Expected success despite invalid auth, got: {:?}", + result + ); + } + + #[tokio::test] + async fn test_process_unsigned_xdr_with_simulation_invalid_tx_data() { + let relayer_address = TEST_PK; + let relayer_id = "test-relayer"; + + let (counter, provider, signer) = + setup_simulation_mocks(5000, "invalid-xdr-data".to_string(), vec![]); + + let args = create_soroswap_swap_args(relayer_address); + let envelope = + create_soroswap_invoke_envelope(relayer_address, "swap_exact_tokens_for_tokens", args); + let stellar_data = create_stellar_data_with_invoke(relayer_address, &envelope); + + let dex_service = MockStellarDexServiceTrait::new(); + let result = process_unsigned_xdr( + &counter, + relayer_id, + relayer_address, + stellar_data, + &provider, + &signer, + None, + &dex_service, + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!( + msg.contains("Failed to parse simulation transaction_data"), + "Error message was: {}", + msg + ); + } + other => panic!("Expected ValidationError, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_process_unsigned_xdr_with_simulation_fee_calculation() { + let relayer_address = TEST_PK; + let relayer_id = "test-relayer"; + let tx_data_xdr = create_valid_soroban_tx_data_xdr(); + let min_resource_fee = 7500u64; + + let (counter, provider, signer) = + setup_simulation_mocks(min_resource_fee, tx_data_xdr, vec![]); + + let args = create_soroswap_swap_args(relayer_address); + let envelope = + create_soroswap_invoke_envelope(relayer_address, "swap_exact_tokens_for_tokens", args); + let stellar_data = create_stellar_data_with_invoke(relayer_address, &envelope); + + let dex_service = MockStellarDexServiceTrait::new(); + let result = process_unsigned_xdr( + &counter, + relayer_id, + relayer_address, + stellar_data, + &provider, + &signer, + None, + &dex_service, + ) + .await; + + assert!(result.is_ok(), "Expected success, got: {:?}", result); + let updated_data = result.unwrap(); + // fee = 1 op * 100 (STELLAR_DEFAULT_TRANSACTION_FEE) + 7500 = 7600 + assert_eq!(updated_data.fee, Some(100 + min_resource_fee as u32)); + } + + #[tokio::test] + async fn test_process_unsigned_xdr_with_simulation_error_response() { + let relayer_address = TEST_PK; + let relayer_id = "test-relayer"; + + let mut counter = MockTransactionCounterTrait::new(); + counter + .expect_get_and_increment() + .returning(|_, _| Box::pin(ready(Ok(42)))); + + let mut provider = MockStellarProviderTrait::new(); + provider + .expect_simulate_transaction_envelope() + .returning(|_| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::SimulateTransactionResponse { + error: Some("Simulation failed: insufficient resources".to_string()), + ..Default::default() + }, + ))) + }); + + let mut signer = MockSigner::new(); + signer.expect_address().returning(|| { + Box::pin(ready(Ok(crate::models::Address::Stellar( + "test-signer-address".to_string(), + )))) + }); + + let args = create_soroswap_swap_args(relayer_address); + let envelope = + create_soroswap_invoke_envelope(relayer_address, "swap_exact_tokens_for_tokens", args); + let stellar_data = create_stellar_data_with_invoke(relayer_address, &envelope); + + let dex_service = MockStellarDexServiceTrait::new(); + let result = process_unsigned_xdr( + &counter, + relayer_id, + relayer_address, + stellar_data, + &provider, + &signer, + None, + &dex_service, + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::SimulationFailed(msg) => { + assert!(msg.contains("Simulation failed")); + } + other => panic!("Expected SimulationFailed, got: {:?}", other), + } + } } #[cfg(test)] diff --git a/src/domain/transaction/stellar/status.rs b/src/domain/transaction/stellar/status.rs index f53556f20..024da7d3c 100644 --- a/src/domain/transaction/stellar/status.rs +++ b/src/domain/transaction/stellar/status.rs @@ -21,7 +21,10 @@ use crate::{ TransactionStatus, TransactionUpdateRequest, }, repositories::{Repository, TransactionCounterTrait, TransactionRepository}, - services::{provider::StellarProviderTrait, signer::Signer}, + services::{ + provider::StellarProviderTrait, + signer::{Signer, StellarSignTrait}, + }, }; impl StellarRelayerTransaction @@ -29,7 +32,7 @@ where R: Repository + Send + Sync, T: TransactionRepository + Send + Sync, J: JobProducerTrait + Send + Sync, - S: Signer + Send + Sync, + S: Signer + StellarSignTrait + Send + Sync, P: StellarProviderTrait + Send + Sync, C: TransactionCounterTrait + Send + Sync, D: crate::services::stellar_dex::StellarDexServiceTrait + Send + Sync + 'static, @@ -2002,8 +2005,7 @@ mod tests { MockRelayerRepository, MockTransactionCounterTrait, MockTransactionRepository, }, services::{ - provider::MockStellarProviderTrait, signer::MockSigner, - stellar_dex::MockStellarDexServiceTrait, + provider::MockStellarProviderTrait, stellar_dex::MockStellarDexServiceTrait, }, }; use chrono::{Duration, Utc}; @@ -2013,7 +2015,7 @@ mod tests { MockRelayerRepository, MockTransactionRepository, MockJobProducerTrait, - MockSigner, + MockStellarCombinedSigner, MockStellarProviderTrait, MockTransactionCounterTrait, MockStellarDexServiceTrait, diff --git a/src/domain/transaction/stellar/stellar_transaction.rs b/src/domain/transaction/stellar/stellar_transaction.rs index 1e6bc1545..25a8ad48b 100644 --- a/src/domain/transaction/stellar/stellar_transaction.rs +++ b/src/domain/transaction/stellar/stellar_transaction.rs @@ -18,8 +18,8 @@ use crate::{ }, services::{ provider::{StellarProvider, StellarProviderTrait}, - signer::{Signer, StellarSigner}, - stellar_dex::{OrderBookService, StellarDexServiceTrait}, + signer::{Signer, StellarSignTrait, StellarSigner}, + stellar_dex::{StellarDexService, StellarDexServiceTrait}, }, utils::calculate_scheduled_timestamp, }; @@ -35,7 +35,7 @@ where R: Repository, T: TransactionRepository, J: JobProducerTrait, - S: Signer, + S: Signer + StellarSignTrait, P: StellarProviderTrait, C: TransactionCounterTrait, D: StellarDexServiceTrait + Send + Sync + 'static, @@ -56,7 +56,7 @@ where R: Repository, T: TransactionRepository, J: JobProducerTrait, - S: Signer, + S: Signer + StellarSignTrait, P: StellarProviderTrait, C: TransactionCounterTrait, D: StellarDexServiceTrait + Send + Sync + 'static, @@ -295,7 +295,7 @@ where R: Repository + Send + Sync, T: TransactionRepository + Send + Sync, J: JobProducerTrait + Send + Sync, - S: Signer + Send + Sync, + S: Signer + StellarSignTrait + Send + Sync, P: StellarProviderTrait + Send + Sync, C: TransactionCounterTrait + Send + Sync, D: StellarDexServiceTrait + Send + Sync + 'static, @@ -366,7 +366,7 @@ pub type DefaultStellarTransaction = StellarRelayerTransaction< StellarSigner, StellarProvider, TransactionCounterRepositoryStorage, - OrderBookService, + StellarDexService, >; #[cfg(test)] diff --git a/src/domain/transaction/stellar/submit.rs b/src/domain/transaction/stellar/submit.rs index 5fa100a64..3166c6091 100644 --- a/src/domain/transaction/stellar/submit.rs +++ b/src/domain/transaction/stellar/submit.rs @@ -13,7 +13,10 @@ use crate::{ TransactionStatus, TransactionUpdateRequest, }, repositories::{Repository, TransactionCounterTrait, TransactionRepository}, - services::{provider::StellarProviderTrait, signer::Signer}, + services::{ + provider::StellarProviderTrait, + signer::{Signer, StellarSignTrait}, + }, }; impl StellarRelayerTransaction @@ -21,7 +24,7 @@ where R: Repository + Send + Sync, T: TransactionRepository + Send + Sync, J: JobProducerTrait + Send + Sync, - S: Signer + Send + Sync, + S: Signer + StellarSignTrait + Send + Sync, P: StellarProviderTrait + Send + Sync, C: TransactionCounterTrait + Send + Sync, D: crate::services::stellar_dex::StellarDexServiceTrait + Send + Sync + 'static, diff --git a/src/domain/transaction/stellar/test_helpers.rs b/src/domain/transaction/stellar/test_helpers.rs index 7730b8b16..3be1194c5 100644 --- a/src/domain/transaction/stellar/test_helpers.rs +++ b/src/domain/transaction/stellar/test_helpers.rs @@ -1,5 +1,9 @@ #[cfg(test)] use crate::domain::transaction::stellar::StellarRelayerTransaction; +#[cfg(test)] +use crate::models::SignerError; +#[cfg(test)] +use crate::services::signer::{MockStellarSignTrait, Signer, StellarSignTrait}; use crate::{ jobs::MockJobProducerTrait, models::{ @@ -8,11 +12,10 @@ use crate::{ TransactionRepoModel, TransactionStatus, }, repositories::{MockRelayerRepository, MockTransactionCounterTrait, MockTransactionRepository}, - services::{ - provider::MockStellarProviderTrait, signer::MockSigner, - stellar_dex::MockStellarDexServiceTrait, - }, + services::{provider::MockStellarProviderTrait, stellar_dex::MockStellarDexServiceTrait}, }; +#[cfg(test)] +use async_trait::async_trait; use chrono::Utc; use soroban_rs::xdr::{ AccountId, Asset, BytesM, Limits, Memo, MuxedAccount, Operation, OperationBody, PaymentOp, @@ -269,12 +272,85 @@ pub fn create_test_transaction(relayer_id: &str) -> TransactionRepoModel { } } +/// A combined mock signer that implements both `Signer` and `StellarSignTrait` +/// +/// This struct wraps both `MockSigner` and `MockStellarSignTrait` to allow tests +/// to set expectations on both the base `Signer` trait methods (via `signer_mock`) +/// and the Stellar-specific methods (via `stellar_mock`). +#[cfg(test)] +pub struct MockStellarCombinedSigner { + pub signer_mock: crate::services::signer::MockSigner, + pub stellar_mock: MockStellarSignTrait, +} + +#[cfg(test)] +impl MockStellarCombinedSigner { + pub fn new() -> Self { + Self { + signer_mock: crate::services::signer::MockSigner::new(), + stellar_mock: MockStellarSignTrait::new(), + } + } +} + +#[cfg(test)] +impl Default for MockStellarCombinedSigner { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +impl std::ops::Deref for MockStellarCombinedSigner { + type Target = crate::services::signer::MockSigner; + + fn deref(&self) -> &Self::Target { + &self.signer_mock + } +} + +#[cfg(test)] +impl std::ops::DerefMut for MockStellarCombinedSigner { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.signer_mock + } +} + +#[cfg(test)] +#[async_trait] +impl Signer for MockStellarCombinedSigner { + async fn address(&self) -> Result { + self.signer_mock.address().await + } + + async fn sign_transaction( + &self, + transaction: NetworkTransactionData, + ) -> Result { + self.signer_mock.sign_transaction(transaction).await + } +} + +#[cfg(test)] +#[async_trait] +impl StellarSignTrait for MockStellarCombinedSigner { + async fn sign_xdr_transaction( + &self, + unsigned_xdr: &str, + network_passphrase: &str, + ) -> Result { + self.stellar_mock + .sign_xdr_transaction(unsigned_xdr, network_passphrase) + .await + } +} + pub struct TestMocks { pub provider: MockStellarProviderTrait, pub relayer_repo: MockRelayerRepository, pub tx_repo: MockTransactionRepository, pub job_producer: MockJobProducerTrait, - pub signer: MockSigner, + pub signer: MockStellarCombinedSigner, pub counter: MockTransactionCounterTrait, pub dex_service: MockStellarDexServiceTrait, } @@ -285,7 +361,7 @@ pub fn default_test_mocks() -> TestMocks { relayer_repo: MockRelayerRepository::new(), tx_repo: MockTransactionRepository::new(), job_producer: MockJobProducerTrait::new(), - signer: MockSigner::new(), + signer: MockStellarCombinedSigner::new(), counter: MockTransactionCounterTrait::new(), dex_service: MockStellarDexServiceTrait::new(), } @@ -299,7 +375,7 @@ pub fn make_stellar_tx_handler( MockRelayerRepository, MockTransactionRepository, MockJobProducerTrait, - MockSigner, + MockStellarCombinedSigner, MockStellarProviderTrait, MockTransactionCounterTrait, MockStellarDexServiceTrait, diff --git a/src/domain/transaction/stellar/token.rs b/src/domain/transaction/stellar/token.rs index f9f828489..e3da0958f 100644 --- a/src/domain/transaction/stellar/token.rs +++ b/src/domain/transaction/stellar/token.rs @@ -7,9 +7,9 @@ use crate::domain::transaction::stellar::utils::{ use crate::models::{StellarTokenKind, StellarTokenMetadata}; use crate::services::provider::StellarProviderTrait; use soroban_rs::xdr::{ - AccountId, AlphaNum12, AlphaNum4, Asset, AssetCode12, AssetCode4, ContractDataEntry, - ContractId, Hash, LedgerEntryData, LedgerKey, ScAddress, ScSymbol, ScVal, TrustLineEntry, - TrustLineEntryExt, TrustLineEntryV1, + AccountId, AlphaNum12, AlphaNum4, Asset, AssetCode12, AssetCode4, ContractId, Hash, + LedgerEntryData, LedgerKey, ScAddress, ScSymbol, ScVal, TrustLineEntry, TrustLineEntryExt, + TrustLineEntryV1, }; use std::str::FromStr; use tracing::{debug, trace, warn}; @@ -246,7 +246,16 @@ where } } -/// Fetch balance for a Soroban contract token via ContractData +/// Fetch balance for a Soroban contract token by invoking the balance() function +/// +/// This function works for all SEP-41 compliant tokens: +/// - SAC (Stellar Asset Contract) tokens +/// - Native Soroban tokens +/// +/// Uses simulation to invoke the contract's balance(id: Address) -> i128 function. +/// This approach is simpler and more reliable than direct storage queries because +/// it lets the contract handle the balance lookup internally (SAC tokens delegate +/// to classic trustlines, native tokens read from contract storage). async fn get_contract_token_balance

( provider: &P, account_id: &str, @@ -255,64 +264,53 @@ async fn get_contract_token_balance

( where P: StellarProviderTrait + Send + Sync, { - // Parse contract address and account ID - let contract_hash = parse_contract_address(contract_address)?; + // Build the account address as ScVal::Address let account_xdr_id = parse_account_id(account_id)?; let account_sc_address = ScAddress::Account(account_xdr_id); - // Create balance key (Soroban token standard uses "Balance" as the key) - let balance_key = create_contract_data_key("Balance", Some(account_sc_address))?; - - // Query contract data with durability fallback - let error_context = format!("contract {contract_address} balance for account {account_id}"); - let ledger_entries = - query_contract_data_with_fallback(provider, contract_hash, balance_key, &error_context) - .await?; - - // Extract balance from contract data entry - let entries = match ledger_entries.entries { - Some(entries) if !entries.is_empty() => entries, - _ => { - // No balance entry means balance is 0 - warn!( - "No balance entry found for contract {} on account {}, assuming zero balance", - contract_address, account_id - ); - return Ok(0); - } - }; + // Create the "balance" function name symbol + let function_name = ScSymbol::try_from("balance".as_bytes().to_vec()).map_err(|e| { + StellarTransactionUtilsError::SymbolCreationFailed("balance".into(), format!("{e:?}")) + })?; - let entry_result = &entries[0]; - let entry = parse_ledger_entry_from_xdr(&entry_result.xdr, &error_context)?; + // Call balance(id: Address) -> i128 via simulation + debug!( + "Querying balance for account {} on contract {} via simulation", + account_id, contract_address + ); - match entry { - LedgerEntryData::ContractData(ContractDataEntry { val, .. }) => match val { - ScVal::I128(parts) => { - if parts.hi != 0 { - return Err(StellarTransactionUtilsError::BalanceTooLarge( - parts.hi, parts.lo, - )); - } - // Check if parts.lo represents a negative value when interpreted as i64 - // Similar to the I64 branch, we check for negative before casting to u64 - let lo_as_i64 = parts.lo as i64; - if lo_as_i64 < 0 { - return Err(StellarTransactionUtilsError::NegativeBalanceI128(parts.lo)); - } - Ok(lo_as_i64 as u64) + let result = provider + .call_contract( + contract_address, + &function_name, + vec![ScVal::Address(account_sc_address)], + ) + .await + .map_err(|e| StellarTransactionUtilsError::SimulationFailed(e.to_string()))?; + + // Parse i128 result to u64 + match result { + ScVal::I128(parts) => { + // Check for overflow (hi should be 0 for values that fit in u64) + if parts.hi != 0 { + return Err(StellarTransactionUtilsError::BalanceTooLarge( + parts.hi, parts.lo, + )); } - ScVal::U64(n) => Ok(n), - ScVal::I64(n) => { - if n < 0 { - return Err(StellarTransactionUtilsError::NegativeBalanceI64(n)); - } - Ok(n as u64) + // Check for negative balance + let lo_as_i64 = parts.lo as i64; + if lo_as_i64 < 0 { + return Err(StellarTransactionUtilsError::NegativeBalanceI128(parts.lo)); } - other => Err(StellarTransactionUtilsError::UnexpectedBalanceType( - format!("{other:?}"), - )), - }, - _ => Err(StellarTransactionUtilsError::UnexpectedContractDataEntryType), + debug!( + "Balance for account {} on contract {}: {}", + account_id, contract_address, lo_as_i64 + ); + Ok(lo_as_i64 as u64) + } + other => Err(StellarTransactionUtilsError::UnexpectedBalanceType( + format!("{other:?}"), + )), } } @@ -1344,17 +1342,14 @@ mod tests { #[tokio::test] async fn test_get_token_balance_contract_token_no_balance_entry() { + use soroban_rs::xdr::Int128Parts; + let mut provider = create_mock_provider(); - // Mock empty response (no balance entry) - provider.expect_get_ledger_entries().returning(|_| { - Box::pin(ready(Ok( - soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse { - entries: None, - latest_ledger: 0, - }, - ))) - }); + // Mock call_contract to return 0 balance (contract returns 0 for non-existent balances) + provider + .expect_call_contract() + .returning(|_, _, _| Box::pin(ready(Ok(ScVal::I128(Int128Parts { hi: 0, lo: 0 }))))); let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; let account = TEST_PK; @@ -1368,46 +1363,13 @@ mod tests { #[tokio::test] async fn test_get_token_balance_contract_token_i128_balance() { - use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult}; - use soroban_rs::xdr::{ - ContractDataDurability, ContractDataEntry, ExtensionPoint, Int128Parts, LedgerEntry, - LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr, - }; + use soroban_rs::xdr::Int128Parts; let mut provider = create_mock_provider(); - // Mock response with I128 balance - provider.expect_get_ledger_entries().returning(|_| { - let balance_val = ScVal::I128(Int128Parts { hi: 0, lo: 1000000 }); - - let contract_data = ContractDataEntry { - ext: ExtensionPoint::V0, - contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))), - key: ScVal::Vec(None), - durability: ContractDataDurability::Persistent, - val: balance_val, - }; - - let ledger_entry = LedgerEntry { - last_modified_ledger_seq: 0, - data: LedgerEntryData::ContractData(contract_data), - ext: LedgerEntryExt::V0, - }; - - let xdr_base64 = ledger_entry - .data - .to_xdr_base64(soroban_rs::xdr::Limits::none()) - .unwrap(); - - Box::pin(ready(Ok(GetLedgerEntriesResponse { - entries: Some(vec![LedgerEntryResult { - key: String::new(), - xdr: xdr_base64, - last_modified_ledger: 0, - live_until_ledger_seq_ledger_seq: None, - }]), - latest_ledger: 0, - }))) + // Mock call_contract to return I128 balance + provider.expect_call_contract().returning(|_, _, _| { + Box::pin(ready(Ok(ScVal::I128(Int128Parts { hi: 0, lo: 1000000 })))) }); let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; @@ -1420,49 +1382,16 @@ mod tests { #[tokio::test] async fn test_get_token_balance_contract_token_i128_balance_too_large() { - use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult}; - use soroban_rs::xdr::{ - ContractDataDurability, ContractDataEntry, ExtensionPoint, Int128Parts, LedgerEntry, - LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr, - }; + use soroban_rs::xdr::Int128Parts; let mut provider = create_mock_provider(); - // Mock response with I128 balance where hi != 0 - provider.expect_get_ledger_entries().returning(|_| { - let balance_val = ScVal::I128(Int128Parts { + // Mock call_contract to return I128 balance with hi != 0 (too large) + provider.expect_call_contract().returning(|_, _, _| { + Box::pin(ready(Ok(ScVal::I128(Int128Parts { hi: 1, // Non-zero hi means balance is too large lo: 1000000, - }); - - let contract_data = ContractDataEntry { - ext: ExtensionPoint::V0, - contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))), - key: ScVal::Vec(None), - durability: ContractDataDurability::Persistent, - val: balance_val, - }; - - let ledger_entry = LedgerEntry { - last_modified_ledger_seq: 0, - data: LedgerEntryData::ContractData(contract_data), - ext: LedgerEntryExt::V0, - }; - - let xdr_base64 = ledger_entry - .data - .to_xdr_base64(soroban_rs::xdr::Limits::none()) - .unwrap(); - - Box::pin(ready(Ok(GetLedgerEntriesResponse { - entries: Some(vec![LedgerEntryResult { - key: String::new(), - xdr: xdr_base64, - last_modified_ledger: 0, - live_until_ledger_seq_ledger_seq: None, - }]), - latest_ledger: 0, - }))) + })))) }); let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; @@ -1481,49 +1410,16 @@ mod tests { #[tokio::test] async fn test_get_token_balance_contract_token_i128_negative() { - use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult}; - use soroban_rs::xdr::{ - ContractDataDurability, ContractDataEntry, ExtensionPoint, Int128Parts, LedgerEntry, - LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr, - }; + use soroban_rs::xdr::Int128Parts; let mut provider = create_mock_provider(); - // Mock response with negative I128 balance - provider.expect_get_ledger_entries().returning(|_| { - let balance_val = ScVal::I128(Int128Parts { + // Mock call_contract to return negative I128 balance + provider.expect_call_contract().returning(|_, _, _| { + Box::pin(ready(Ok(ScVal::I128(Int128Parts { hi: 0, lo: u64::MAX, // When cast to i64, this is negative - }); - - let contract_data = ContractDataEntry { - ext: ExtensionPoint::V0, - contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))), - key: ScVal::Vec(None), - durability: ContractDataDurability::Persistent, - val: balance_val, - }; - - let ledger_entry = LedgerEntry { - last_modified_ledger_seq: 0, - data: LedgerEntryData::ContractData(contract_data), - ext: LedgerEntryExt::V0, - }; - - let xdr_base64 = ledger_entry - .data - .to_xdr_base64(soroban_rs::xdr::Limits::none()) - .unwrap(); - - Box::pin(ready(Ok(GetLedgerEntriesResponse { - entries: Some(vec![LedgerEntryResult { - key: String::new(), - xdr: xdr_base64, - last_modified_ledger: 0, - live_until_ledger_seq_ledger_seq: None, - }]), - latest_ledger: 0, - }))) + })))) }); let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; @@ -1539,208 +1435,75 @@ mod tests { #[tokio::test] async fn test_get_token_balance_contract_token_u64_balance() { - use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult}; - use soroban_rs::xdr::{ - ContractDataDurability, ContractDataEntry, ExtensionPoint, LedgerEntry, - LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr, - }; - let mut provider = create_mock_provider(); - // Mock response with U64 balance - provider.expect_get_ledger_entries().returning(|_| { - let balance_val = ScVal::U64(5000000); - - let contract_data = ContractDataEntry { - ext: ExtensionPoint::V0, - contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))), - key: ScVal::Vec(None), - durability: ContractDataDurability::Persistent, - val: balance_val, - }; - - let ledger_entry = LedgerEntry { - last_modified_ledger_seq: 0, - data: LedgerEntryData::ContractData(contract_data), - ext: LedgerEntryExt::V0, - }; - - let xdr_base64 = ledger_entry - .data - .to_xdr_base64(soroban_rs::xdr::Limits::none()) - .unwrap(); - - Box::pin(ready(Ok(GetLedgerEntriesResponse { - entries: Some(vec![LedgerEntryResult { - key: String::new(), - xdr: xdr_base64, - last_modified_ledger: 0, - live_until_ledger_seq_ledger_seq: None, - }]), - latest_ledger: 0, - }))) - }); + // Mock call_contract to return U64 balance - this is unexpected for balance() which returns i128 + provider + .expect_call_contract() + .returning(|_, _, _| Box::pin(ready(Ok(ScVal::U64(5000000))))); let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; let account = TEST_PK; let result = get_token_balance(&provider, account, contract_addr).await; - assert!(result.is_ok()); - assert_eq!(result.unwrap(), 5000000); + // U64 is not a valid return type for balance(), should return UnexpectedBalanceType + assert!(result.is_err()); + match result.unwrap_err() { + StellarTransactionUtilsError::UnexpectedBalanceType(_) => {} + e => panic!("Expected UnexpectedBalanceType, got: {:?}", e), + } } #[tokio::test] async fn test_get_token_balance_contract_token_i64_positive() { - use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult}; - use soroban_rs::xdr::{ - ContractDataDurability, ContractDataEntry, ExtensionPoint, LedgerEntry, - LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr, - }; - let mut provider = create_mock_provider(); - // Mock response with positive I64 balance - provider.expect_get_ledger_entries().returning(|_| { - let balance_val = ScVal::I64(3000000); - - let contract_data = ContractDataEntry { - ext: ExtensionPoint::V0, - contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))), - key: ScVal::Vec(None), - durability: ContractDataDurability::Persistent, - val: balance_val, - }; - - let ledger_entry = LedgerEntry { - last_modified_ledger_seq: 0, - data: LedgerEntryData::ContractData(contract_data), - ext: LedgerEntryExt::V0, - }; - - let xdr_base64 = ledger_entry - .data - .to_xdr_base64(soroban_rs::xdr::Limits::none()) - .unwrap(); - - Box::pin(ready(Ok(GetLedgerEntriesResponse { - entries: Some(vec![LedgerEntryResult { - key: String::new(), - xdr: xdr_base64, - last_modified_ledger: 0, - live_until_ledger_seq_ledger_seq: None, - }]), - latest_ledger: 0, - }))) - }); + // Mock call_contract to return I64 balance - this is unexpected for balance() which returns i128 + provider + .expect_call_contract() + .returning(|_, _, _| Box::pin(ready(Ok(ScVal::I64(3000000))))); let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; let account = TEST_PK; let result = get_token_balance(&provider, account, contract_addr).await; - assert!(result.is_ok()); - assert_eq!(result.unwrap(), 3000000); + // I64 is not a valid return type for balance(), should return UnexpectedBalanceType + assert!(result.is_err()); + match result.unwrap_err() { + StellarTransactionUtilsError::UnexpectedBalanceType(_) => {} + e => panic!("Expected UnexpectedBalanceType, got: {:?}", e), + } } #[tokio::test] async fn test_get_token_balance_contract_token_i64_negative() { - use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult}; - use soroban_rs::xdr::{ - ContractDataDurability, ContractDataEntry, ExtensionPoint, LedgerEntry, - LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr, - }; - let mut provider = create_mock_provider(); - // Mock response with negative I64 balance - provider.expect_get_ledger_entries().returning(|_| { - let balance_val = ScVal::I64(-1000); - - let contract_data = ContractDataEntry { - ext: ExtensionPoint::V0, - contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))), - key: ScVal::Vec(None), - durability: ContractDataDurability::Persistent, - val: balance_val, - }; - - let ledger_entry = LedgerEntry { - last_modified_ledger_seq: 0, - data: LedgerEntryData::ContractData(contract_data), - ext: LedgerEntryExt::V0, - }; - - let xdr_base64 = ledger_entry - .data - .to_xdr_base64(soroban_rs::xdr::Limits::none()) - .unwrap(); - - Box::pin(ready(Ok(GetLedgerEntriesResponse { - entries: Some(vec![LedgerEntryResult { - key: String::new(), - xdr: xdr_base64, - last_modified_ledger: 0, - live_until_ledger_seq_ledger_seq: None, - }]), - latest_ledger: 0, - }))) - }); + // Mock call_contract to return I64 balance - this is unexpected for balance() which returns i128 + provider + .expect_call_contract() + .returning(|_, _, _| Box::pin(ready(Ok(ScVal::I64(-1000))))); let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; let account = TEST_PK; let result = get_token_balance(&provider, account, contract_addr).await; + // I64 is not a valid return type for balance(), should return UnexpectedBalanceType assert!(result.is_err()); match result.unwrap_err() { - StellarTransactionUtilsError::NegativeBalanceI64(n) => { - assert_eq!(n, -1000); - } - e => panic!("Expected NegativeBalanceI64, got: {:?}", e), + StellarTransactionUtilsError::UnexpectedBalanceType(_) => {} + e => panic!("Expected UnexpectedBalanceType, got: {:?}", e), } } #[tokio::test] async fn test_get_token_balance_contract_token_unexpected_balance_type() { - use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult}; - use soroban_rs::xdr::{ - ContractDataDurability, ContractDataEntry, ExtensionPoint, LedgerEntry, - LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr, - }; - let mut provider = create_mock_provider(); - // Mock response with unexpected balance type (Bool) - provider.expect_get_ledger_entries().returning(|_| { - let balance_val = ScVal::Bool(true); - - let contract_data = ContractDataEntry { - ext: ExtensionPoint::V0, - contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))), - key: ScVal::Vec(None), - durability: ContractDataDurability::Persistent, - val: balance_val, - }; - - let ledger_entry = LedgerEntry { - last_modified_ledger_seq: 0, - data: LedgerEntryData::ContractData(contract_data), - ext: LedgerEntryExt::V0, - }; - - let xdr_base64 = ledger_entry - .data - .to_xdr_base64(soroban_rs::xdr::Limits::none()) - .unwrap(); - - Box::pin(ready(Ok(GetLedgerEntriesResponse { - entries: Some(vec![LedgerEntryResult { - key: String::new(), - xdr: xdr_base64, - last_modified_ledger: 0, - live_until_ledger_seq_ledger_seq: None, - }]), - latest_ledger: 0, - }))) - }); + // Mock call_contract to return unexpected balance type (Bool) + provider + .expect_call_contract() + .returning(|_, _, _| Box::pin(ready(Ok(ScVal::Bool(true))))); let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; let account = TEST_PK; diff --git a/src/domain/transaction/stellar/utils.rs b/src/domain/transaction/stellar/utils.rs index 6edb6255d..2ad8df821 100644 --- a/src/domain/transaction/stellar/utils.rs +++ b/src/domain/transaction/stellar/utils.rs @@ -67,6 +67,12 @@ pub enum StellarTransactionUtilsError { #[error("Cannot set time bounds on fee-bump transactions")] CannotSetTimeBoundsOnFeeBump, + #[error("V0 transactions are not supported")] + V0TransactionsNotSupported, + + #[error("Cannot update sequence number on fee bump transaction")] + CannotUpdateSequenceOnFeeBump, + #[error("Invalid transaction format: {0}")] InvalidTransactionFormat(String), @@ -184,6 +190,14 @@ impl From for RelayerError { "Cannot set time bounds on fee-bump transactions".to_string(), ) } + StellarTransactionUtilsError::V0TransactionsNotSupported => { + RelayerError::ValidationError("V0 transactions are not supported".to_string()) + } + StellarTransactionUtilsError::CannotUpdateSequenceOnFeeBump => { + RelayerError::ValidationError( + "Cannot update sequence number on fee bump transaction".to_string(), + ) + } StellarTransactionUtilsError::InvalidAccountAddress(_, msg) | StellarTransactionUtilsError::InvalidContractAddress(_, msg) | StellarTransactionUtilsError::SymbolCreationFailed(_, msg) @@ -356,6 +370,39 @@ pub fn create_transaction_signature_payload( } } +/// Update the sequence number in a transaction envelope. +/// +/// Only V1 (Tx) envelopes are supported; V0 and fee-bump envelopes return an error. +pub fn update_envelope_sequence( + envelope: &mut TransactionEnvelope, + sequence: i64, +) -> Result<(), StellarTransactionUtilsError> { + match envelope { + TransactionEnvelope::Tx(v1) => { + v1.tx.seq_num = soroban_rs::xdr::SequenceNumber(sequence); + Ok(()) + } + TransactionEnvelope::TxV0(_) => { + Err(StellarTransactionUtilsError::V0TransactionsNotSupported) + } + TransactionEnvelope::TxFeeBump(_) => { + Err(StellarTransactionUtilsError::CannotUpdateSequenceOnFeeBump) + } + } +} + +/// Extract the fee (in stroops) from a V1 transaction envelope. +pub fn envelope_fee_in_stroops( + envelope: &TransactionEnvelope, +) -> Result { + match envelope { + TransactionEnvelope::Tx(env) => Ok(u64::from(env.tx.fee)), + _ => Err(StellarTransactionUtilsError::InvalidTransactionFormat( + "Expected V1 transaction envelope".to_string(), + )), + } +} + // ============================================================================ // Account and Contract Address Utilities // ============================================================================ @@ -1655,6 +1702,161 @@ mod parse_contract_address_tests { } } +// ============================================================================ +// Update Envelope Sequence and Envelope Fee Tests +// ============================================================================ + +#[cfg(test)] +mod update_envelope_sequence_tests { + use super::*; + use soroban_rs::xdr::{ + FeeBumpTransaction, FeeBumpTransactionEnvelope, FeeBumpTransactionExt, + FeeBumpTransactionInnerTx, Memo, MuxedAccount, Preconditions, SequenceNumber, Transaction, + TransactionExt, TransactionV0, TransactionV0Envelope, TransactionV0Ext, + TransactionV1Envelope, Uint256, VecM, + }; + + fn create_minimal_v1_envelope() -> TransactionEnvelope { + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])), + fee: 100, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: VecM::default(), + ext: TransactionExt::V0, + }; + TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }) + } + + fn create_v0_envelope() -> TransactionEnvelope { + let tx = TransactionV0 { + source_account_ed25519: Uint256([0u8; 32]), + fee: 100, + seq_num: SequenceNumber(0), + time_bounds: None, + memo: Memo::None, + operations: VecM::default(), + ext: TransactionV0Ext::V0, + }; + TransactionEnvelope::TxV0(TransactionV0Envelope { + tx, + signatures: VecM::default(), + }) + } + + fn create_fee_bump_envelope() -> TransactionEnvelope { + let inner_tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])), + fee: 100, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: VecM::default(), + ext: TransactionExt::V0, + }; + let inner_envelope = TransactionV1Envelope { + tx: inner_tx, + signatures: VecM::default(), + }; + let fee_bump_tx = FeeBumpTransaction { + fee_source: MuxedAccount::Ed25519(Uint256([1u8; 32])), + fee: 200, + inner_tx: FeeBumpTransactionInnerTx::Tx(inner_envelope), + ext: FeeBumpTransactionExt::V0, + }; + TransactionEnvelope::TxFeeBump(FeeBumpTransactionEnvelope { + tx: fee_bump_tx, + signatures: VecM::default(), + }) + } + + #[test] + fn test_update_envelope_sequence() { + let mut envelope = create_minimal_v1_envelope(); + update_envelope_sequence(&mut envelope, 12345).unwrap(); + if let TransactionEnvelope::Tx(v1) = &envelope { + assert_eq!(v1.tx.seq_num.0, 12345); + } else { + panic!("Expected Tx envelope"); + } + } + + #[test] + fn test_update_envelope_sequence_v0_returns_error() { + let mut envelope = create_v0_envelope(); + let result = update_envelope_sequence(&mut envelope, 12345); + assert!(result.is_err()); + match result.unwrap_err() { + StellarTransactionUtilsError::V0TransactionsNotSupported => {} + _ => panic!("Expected V0TransactionsNotSupported error"), + } + } + + #[test] + fn test_update_envelope_sequence_fee_bump_returns_error() { + let mut envelope = create_fee_bump_envelope(); + let result = update_envelope_sequence(&mut envelope, 12345); + assert!(result.is_err()); + match result.unwrap_err() { + StellarTransactionUtilsError::CannotUpdateSequenceOnFeeBump => {} + _ => panic!("Expected CannotUpdateSequenceOnFeeBump error"), + } + } + + #[test] + fn test_update_envelope_sequence_zero() { + let mut envelope = create_minimal_v1_envelope(); + update_envelope_sequence(&mut envelope, 0).unwrap(); + if let TransactionEnvelope::Tx(v1) = &envelope { + assert_eq!(v1.tx.seq_num.0, 0); + } else { + panic!("Expected Tx envelope"); + } + } + + #[test] + fn test_update_envelope_sequence_max_value() { + let mut envelope = create_minimal_v1_envelope(); + update_envelope_sequence(&mut envelope, i64::MAX).unwrap(); + if let TransactionEnvelope::Tx(v1) = &envelope { + assert_eq!(v1.tx.seq_num.0, i64::MAX); + } else { + panic!("Expected Tx envelope"); + } + } + + #[test] + fn test_envelope_fee_in_stroops_v1() { + let envelope = create_minimal_v1_envelope(); + let fee = envelope_fee_in_stroops(&envelope).unwrap(); + assert_eq!(fee, 100); + } + + #[test] + fn test_envelope_fee_in_stroops_v0_returns_error() { + let envelope = create_v0_envelope(); + let result = envelope_fee_in_stroops(&envelope); + assert!(result.is_err()); + match result.unwrap_err() { + StellarTransactionUtilsError::InvalidTransactionFormat(msg) => { + assert!(msg.contains("Expected V1")); + } + _ => panic!("Expected InvalidTransactionFormat error"), + } + } + + #[test] + fn test_envelope_fee_in_stroops_fee_bump_returns_error() { + let envelope = create_fee_bump_envelope(); + let result = envelope_fee_in_stroops(&envelope); + assert!(result.is_err()); + } +} + // ============================================================================ // Contract Data Key Tests // ============================================================================ @@ -2891,3 +3093,538 @@ mod set_time_bounds_tests { } } } + +// ============================================================================ +// From for RelayerError Tests +// ============================================================================ + +#[cfg(test)] +mod stellar_transaction_utils_error_conversion_tests { + use super::*; + + #[test] + fn test_v0_transactions_not_supported_converts_to_validation_error() { + let err = StellarTransactionUtilsError::V0TransactionsNotSupported; + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert_eq!(msg, "V0 transactions are not supported"); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_cannot_update_sequence_on_fee_bump_converts_to_validation_error() { + let err = StellarTransactionUtilsError::CannotUpdateSequenceOnFeeBump; + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert_eq!(msg, "Cannot update sequence number on fee bump transaction"); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_cannot_set_time_bounds_on_fee_bump_converts_to_validation_error() { + let err = StellarTransactionUtilsError::CannotSetTimeBoundsOnFeeBump; + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert_eq!(msg, "Cannot set time bounds on fee-bump transactions"); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_invalid_transaction_format_converts_to_validation_error() { + let err = StellarTransactionUtilsError::InvalidTransactionFormat("bad format".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert_eq!(msg, "bad format"); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_cannot_modify_fee_bump_converts_to_validation_error() { + let err = StellarTransactionUtilsError::CannotModifyFeeBump; + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert_eq!(msg, "Cannot add operations to fee-bump transactions"); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_too_many_operations_converts_to_validation_error() { + let err = StellarTransactionUtilsError::TooManyOperations(100); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Too many operations")); + assert!(msg.contains("100")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_sequence_overflow_converts_to_internal_error() { + let err = StellarTransactionUtilsError::SequenceOverflow("overflow msg".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "overflow msg"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_simulation_no_results_converts_to_internal_error() { + let err = StellarTransactionUtilsError::SimulationNoResults; + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert!(msg.contains("no results")); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_asset_code_too_long_converts_to_validation_error() { + let err = + StellarTransactionUtilsError::AssetCodeTooLong(12, "VERYLONGASSETCODE".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Asset code too long")); + assert!(msg.contains("12")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_invalid_asset_format_converts_to_validation_error() { + let err = StellarTransactionUtilsError::InvalidAssetFormat("bad asset".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert_eq!(msg, "bad asset"); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_invalid_account_address_converts_to_internal_error() { + let err = StellarTransactionUtilsError::InvalidAccountAddress( + "GABC".to_string(), + "parse error".to_string(), + ); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "parse error"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_invalid_contract_address_converts_to_internal_error() { + let err = StellarTransactionUtilsError::InvalidContractAddress( + "CABC".to_string(), + "contract parse error".to_string(), + ); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "contract parse error"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_symbol_creation_failed_converts_to_internal_error() { + let err = StellarTransactionUtilsError::SymbolCreationFailed( + "Balance".to_string(), + "too long".to_string(), + ); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "too long"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_key_vector_creation_failed_converts_to_internal_error() { + let err = StellarTransactionUtilsError::KeyVectorCreationFailed( + "Balance".to_string(), + "vec error".to_string(), + ); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "vec error"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_contract_data_query_persistent_failed_converts_to_internal_error() { + let err = StellarTransactionUtilsError::ContractDataQueryPersistentFailed( + "balance".to_string(), + "rpc error".to_string(), + ); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "rpc error"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_contract_data_query_temporary_failed_converts_to_internal_error() { + let err = StellarTransactionUtilsError::ContractDataQueryTemporaryFailed( + "balance".to_string(), + "temp error".to_string(), + ); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "temp error"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_ledger_entry_parse_failed_converts_to_internal_error() { + let err = StellarTransactionUtilsError::LedgerEntryParseFailed( + "entry".to_string(), + "xdr error".to_string(), + ); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "xdr error"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_no_entries_found_converts_to_validation_error() { + let err = StellarTransactionUtilsError::NoEntriesFound("balance".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("No entries found")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_empty_entries_converts_to_validation_error() { + let err = StellarTransactionUtilsError::EmptyEntries("balance".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Empty entries")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_unexpected_ledger_entry_type_converts_to_validation_error() { + let err = StellarTransactionUtilsError::UnexpectedLedgerEntryType("balance".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Unexpected ledger entry type")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_invalid_issuer_length_converts_to_validation_error() { + let err = StellarTransactionUtilsError::InvalidIssuerLength(56, "SHORT".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("56")); + assert!(msg.contains("SHORT")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_invalid_issuer_prefix_converts_to_validation_error() { + let err = StellarTransactionUtilsError::InvalidIssuerPrefix('G', "CABC123".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("'G'")); + assert!(msg.contains("CABC123")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_account_fetch_failed_converts_to_provider_error() { + let err = StellarTransactionUtilsError::AccountFetchFailed("fetch error".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ProviderError(msg) => { + assert_eq!(msg, "fetch error"); + } + _ => panic!("Expected ProviderError"), + } + } + + #[test] + fn test_trustline_query_failed_converts_to_provider_error() { + let err = StellarTransactionUtilsError::TrustlineQueryFailed( + "USDC".to_string(), + "rpc fail".to_string(), + ); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ProviderError(msg) => { + assert_eq!(msg, "rpc fail"); + } + _ => panic!("Expected ProviderError"), + } + } + + #[test] + fn test_contract_invocation_failed_converts_to_provider_error() { + let err = StellarTransactionUtilsError::ContractInvocationFailed( + "transfer".to_string(), + "invoke error".to_string(), + ); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ProviderError(msg) => { + assert_eq!(msg, "invoke error"); + } + _ => panic!("Expected ProviderError"), + } + } + + #[test] + fn test_xdr_parse_failed_converts_to_internal_error() { + let err = StellarTransactionUtilsError::XdrParseFailed("xdr parse fail".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "xdr parse fail"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_operation_extraction_failed_converts_to_internal_error() { + let err = + StellarTransactionUtilsError::OperationExtractionFailed("extract fail".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "extract fail"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_simulation_failed_converts_to_internal_error() { + let err = StellarTransactionUtilsError::SimulationFailed("sim error".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "sim error"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_simulation_check_failed_converts_to_internal_error() { + let err = StellarTransactionUtilsError::SimulationCheckFailed("check fail".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "check fail"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_dex_quote_failed_converts_to_internal_error() { + let err = StellarTransactionUtilsError::DexQuoteFailed("dex error".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "dex error"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_empty_asset_code_converts_to_validation_error() { + let err = StellarTransactionUtilsError::EmptyAssetCode("CODE:ISSUER".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Asset code cannot be empty")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_empty_issuer_address_converts_to_validation_error() { + let err = StellarTransactionUtilsError::EmptyIssuerAddress("USDC:".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Issuer address cannot be empty")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_no_trustline_found_converts_to_validation_error() { + let err = + StellarTransactionUtilsError::NoTrustlineFound("USDC".to_string(), "GABC".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("No trustline found")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_unsupported_trustline_version_converts_to_validation_error() { + let err = StellarTransactionUtilsError::UnsupportedTrustlineVersion; + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Unsupported trustline")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_unexpected_trustline_entry_type_converts_to_validation_error() { + let err = StellarTransactionUtilsError::UnexpectedTrustlineEntryType; + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Unexpected ledger entry type")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_balance_too_large_converts_to_validation_error() { + let err = StellarTransactionUtilsError::BalanceTooLarge(1, 999); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Balance too large")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_negative_balance_i128_converts_to_validation_error() { + let err = StellarTransactionUtilsError::NegativeBalanceI128(42); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Negative balance")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_negative_balance_i64_converts_to_validation_error() { + let err = StellarTransactionUtilsError::NegativeBalanceI64(-5); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Negative balance")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_unexpected_balance_type_converts_to_validation_error() { + let err = StellarTransactionUtilsError::UnexpectedBalanceType("Bool(true)".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Unexpected balance value type")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_unexpected_contract_data_entry_type_converts_to_validation_error() { + let err = StellarTransactionUtilsError::UnexpectedContractDataEntryType; + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Unexpected ledger entry type")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_native_asset_in_trustline_query_converts_to_validation_error() { + let err = StellarTransactionUtilsError::NativeAssetInTrustlineQuery; + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Native asset")); + } + _ => panic!("Expected ValidationError"), + } + } +} diff --git a/src/models/relayer/config.rs b/src/models/relayer/config.rs index 229681fed..b213d4436 100644 --- a/src/models/relayer/config.rs +++ b/src/models/relayer/config.rs @@ -195,6 +195,7 @@ pub struct ConfigFileRelayerStellarPolicy { pub min_balance: Option, pub concurrent_transactions: Option, /// Determines if the relayer pays the transaction fee or the user. Optional. + /// When set to "user" with STELLAR_FEE_FORWARDER_ADDRESS env var, enables soroban gas abstraction as well. pub fee_payment_strategy: Option, /// Default slippage percentage for token conversions. Optional. pub slippage_percentage: Option, diff --git a/src/models/relayer/mod.rs b/src/models/relayer/mod.rs index d9e4f83ad..37729a82e 100644 --- a/src/models/relayer/mod.rs +++ b/src/models/relayer/mod.rs @@ -654,6 +654,11 @@ impl RelayerStellarPolicy { pub fn get_swap_config(&self) -> Option { self.swap_config.clone() } + + /// Check if user fee payment strategy is enabled (gas abstraction requires this + STELLAR_FEE_FORWARDER_ADDRESS env var) + pub fn is_user_fee_payment(&self) -> bool { + self.fee_payment_strategy == Some(StellarFeePaymentStrategy::User) + } } /// Network-specific policy for relayers @@ -1042,12 +1047,9 @@ impl Relayer { // Check if it's a contract address (StrKey format starting with 'C') if asset.starts_with('C') && asset.len() == 56 && !asset.contains(':') { - return Err(RelayerValidationError::InvalidPolicy( - "Contract addresses are not supported. Soroban will be supported soon.".into(), - )); - // // Basic validation - contract addresses are 56 characters starting with 'C' - // // Full validation would require StrKey decoding, but this catches most invalid formats - // return Ok(()); + // Basic validation - contract addresses are 56 characters starting with 'C' + // Full validation would require StrKey decoding, but this catches most invalid formats + return Ok(()); } // Check if it's a classic asset format "CODE:ISSUER" diff --git a/src/models/rpc/stellar/mod.rs b/src/models/rpc/stellar/mod.rs index d2f9a9cac..01a39fc8e 100644 --- a/src/models/rpc/stellar/mod.rs +++ b/src/models/rpc/stellar/mod.rs @@ -12,7 +12,8 @@ use crate::{ #[schema(as = StellarFeeEstimateRequestParams)] pub struct FeeEstimateRequestParams { /// Pre-built transaction XDR (base64 encoded, signed or unsigned) - /// Mutually exclusive with operations field + /// Mutually exclusive with operations field. + /// For Soroban gas abstraction: pass XDR containing InvokeHostFunction operation. #[schema(nullable = true)] pub transaction_xdr: Option, /// Source account address (required when operations are provided) @@ -23,7 +24,9 @@ pub struct FeeEstimateRequestParams { /// Mutually exclusive with transaction_xdr field #[schema(nullable = true)] pub operations: Option>, - /// Asset identifier for fee token (e.g., "native" or "USDC:GA5Z...") + /// Asset identifier for fee token. + /// For classic: "native" or "USDC:GA5Z..." format. + /// For Soroban: contract address (C...) format. pub fee_token: String, } @@ -81,6 +84,16 @@ pub struct FeeEstimateResult { pub fee_in_token: String, /// Conversion rate from XLM to token (as string) pub conversion_rate: String, + /// Maximum fee in token amount (raw units as string). + /// Only present for Soroban gas abstraction - includes slippage buffer. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = true)] + pub max_fee_in_token: Option, + /// Maximum fee in token amount (decimal UI representation as string). + /// Only present for Soroban gas abstraction - includes slippage buffer. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = true)] + pub max_fee_in_token_ui: Option, } // prepareTransaction @@ -90,7 +103,8 @@ pub struct FeeEstimateResult { #[schema(as = StellarPrepareTransactionRequestParams)] pub struct PrepareTransactionRequestParams { /// Pre-built transaction XDR (base64 encoded, signed or unsigned) - /// Mutually exclusive with operations field + /// Mutually exclusive with operations field. + /// For Soroban gas abstraction: pass XDR containing InvokeHostFunction operation. #[schema(nullable = true)] pub transaction_xdr: Option, /// Operations array to build transaction from @@ -101,7 +115,9 @@ pub struct PrepareTransactionRequestParams { /// For gasless transactions, this should be the user's account address #[schema(nullable = true)] pub source_account: Option, - /// Asset identifier for fee token + /// Asset identifier for fee token. + /// For classic: "native" or "USDC:GA5Z..." format. + /// For Soroban: contract address (C...) format. pub fee_token: String, } @@ -167,6 +183,21 @@ pub struct PrepareTransactionResult { pub fee_token: String, /// Transaction validity timestamp (ISO 8601 format) pub valid_until: String, + /// User authorization entry XDR (base64 encoded). + /// Present for Soroban gas abstraction - user must sign this auth entry. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = true)] + pub user_auth_entry: Option, + /// Maximum fee in token amount (raw units as string). + /// Only present for Soroban gas abstraction - includes slippage buffer. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = true)] + pub max_fee_in_token: Option, + /// Maximum fee in token amount (decimal UI representation as string). + /// Only present for Soroban gas abstraction - includes slippage buffer. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = true)] + pub max_fee_in_token_ui: Option, } /// Stellar RPC method enum @@ -511,4 +542,86 @@ mod tests { panic!("Expected BadRequest error for empty operations"); } } + + #[test] + fn test_fee_estimate_result() { + let result = FeeEstimateResult { + fee_in_token_ui: "1.5".to_string(), + fee_in_token: "1500000".to_string(), + conversion_rate: "10.0".to_string(), + max_fee_in_token: None, + max_fee_in_token_ui: None, + }; + assert_eq!(result.fee_in_token_ui, "1.5"); + assert_eq!(result.fee_in_token, "1500000"); + assert_eq!(result.conversion_rate, "10.0"); + } + + #[test] + fn test_fee_estimate_result_with_max_fee() { + let result = FeeEstimateResult { + fee_in_token_ui: "1.5".to_string(), + fee_in_token: "1500000".to_string(), + conversion_rate: "10.0".to_string(), + max_fee_in_token: Some("1575000".to_string()), + max_fee_in_token_ui: Some("1.575".to_string()), + }; + assert_eq!(result.max_fee_in_token, Some("1575000".to_string())); + assert_eq!(result.max_fee_in_token_ui, Some("1.575".to_string())); + // Verify serialization includes max_fee fields when present + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains("max_fee_in_token")); + assert!(json.contains("max_fee_in_token_ui")); + } + + #[test] + fn test_fee_estimate_result_skips_none_max_fee() { + let result = FeeEstimateResult { + fee_in_token_ui: "1.5".to_string(), + fee_in_token: "1500000".to_string(), + conversion_rate: "10.0".to_string(), + max_fee_in_token: None, + max_fee_in_token_ui: None, + }; + // Verify serialization skips None fields + let json = serde_json::to_string(&result).unwrap(); + assert!(!json.contains("max_fee_in_token")); + } + + #[test] + fn test_prepare_transaction_result_with_soroban_fields() { + let result = PrepareTransactionResult { + transaction: "AAAAAgAAAAA=".to_string(), + fee_in_token: "1500000".to_string(), + fee_in_token_ui: "1.5".to_string(), + fee_in_stroops: "150000".to_string(), + fee_token: "CUSDC".to_string(), + valid_until: "2024-01-01T00:00:00Z".to_string(), + user_auth_entry: Some("AAAABgAAAAA=".to_string()), + max_fee_in_token: Some("1575000".to_string()), + max_fee_in_token_ui: Some("1.575".to_string()), + }; + assert!(result.user_auth_entry.is_some()); + assert!(result.max_fee_in_token.is_some()); + assert!(result.max_fee_in_token_ui.is_some()); + } + + #[test] + fn test_prepare_transaction_result_without_soroban_fields() { + let result = PrepareTransactionResult { + transaction: "AAAAAgAAAAA=".to_string(), + fee_in_token: "1500000".to_string(), + fee_in_token_ui: "1.5".to_string(), + fee_in_stroops: "150000".to_string(), + fee_token: "USDC:GA...".to_string(), + valid_until: "2024-01-01T00:00:00Z".to_string(), + user_auth_entry: None, + max_fee_in_token: None, + max_fee_in_token_ui: None, + }; + // Verify serialization skips None fields + let json = serde_json::to_string(&result).unwrap(); + assert!(!json.contains("user_auth_entry")); + assert!(!json.contains("max_fee_in_token")); + } } diff --git a/src/models/transaction/repository.rs b/src/models/transaction/repository.rs index 3739c3ea9..537003d40 100644 --- a/src/models/transaction/repository.rs +++ b/src/models/transaction/repository.rs @@ -33,15 +33,14 @@ use alloy::{ use chrono::{Duration, Utc}; use serde::{Deserialize, Serialize}; +use soroban_rs::xdr::{TransactionEnvelope, TransactionV1Envelope, VecM}; use std::{convert::TryFrom, str::FromStr}; use strum::Display; use utoipa::ToSchema; use uuid::Uuid; -use soroban_rs::xdr::{ - Transaction as SorobanTransaction, TransactionEnvelope, TransactionV1Envelope, VecM, -}; +use soroban_rs::xdr::Transaction as SorobanTransaction; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema, Display)] #[serde(rename_all = "lowercase")] @@ -470,6 +469,13 @@ pub enum TransactionInput { UnsignedXdr(String), /// Pre-built signed XDR that needs fee-bumping SignedXdr { xdr: String, max_fee: i64 }, + /// Soroban gas abstraction: FeeForwarder transaction with user's signed auth entry + /// The XDR is the FeeForwarder transaction from /build, and the signed_auth_entry + /// contains the user's signed SorobanAuthorizationEntry to be injected. + SorobanGasAbstraction { + xdr: String, + signed_auth_entry: String, + }, } impl Default for TransactionInput { @@ -483,6 +489,24 @@ impl TransactionInput { pub fn from_stellar_request( request: &StellarTransactionRequest, ) -> Result { + // Handle Soroban gas abstraction mode (XDR + signed_auth_entry) + if let (Some(xdr), Some(signed_auth_entry)) = + (&request.transaction_xdr, &request.signed_auth_entry) + { + // Validation: signed_auth_entry and fee_bump are mutually exclusive + // (already validated in StellarTransactionRequest::validate(), but double-check here) + if request.fee_bump == Some(true) { + return Err(TransactionError::ValidationError( + "Cannot use both signed_auth_entry and fee_bump".to_string(), + )); + } + + return Ok(TransactionInput::SorobanGasAbstraction { + xdr: xdr.clone(), + signed_auth_entry: signed_auth_entry.clone(), + }); + } + // Handle XDR mode if let Some(xdr) = &request.transaction_xdr { let envelope = parse_transaction_xdr(xdr, false) @@ -641,6 +665,10 @@ impl StellarTransactionData { // Parse the inner transaction (for fee-bump cases) self.parse_xdr_envelope(xdr) } + TransactionInput::SorobanGasAbstraction { xdr, .. } => { + // Parse the FeeForwarder transaction XDR + self.parse_xdr_envelope(xdr) + } } } @@ -683,6 +711,12 @@ impl StellarTransactionData { // Already signed self.parse_xdr_envelope(xdr) } + TransactionInput::SorobanGasAbstraction { xdr, .. } => { + // For Soroban gas abstraction, the signed auth entry is injected during prepare + // Parse and attach the relayer's signature + let envelope = self.parse_xdr_envelope(xdr)?; + self.attach_signatures_to_envelope(envelope) + } } } @@ -917,6 +951,9 @@ impl let valid_until = extract_stellar_valid_until(stellar_request, Utc::now()); + let transaction_input = TransactionInput::from_stellar_request(stellar_request) + .map_err(|e| RelayerError::ValidationError(e.to_string()))?; + let stellar_data = StellarTransactionData { source_account: source_account.unwrap_or_else(|| relayer_model.address.clone()), memo: stellar_request.memo.clone(), @@ -927,8 +964,7 @@ impl fee: None, sequence_number: None, simulation_transaction_data: None, - transaction_input: TransactionInput::from_stellar_request(stellar_request) - .map_err(|e| RelayerError::ValidationError(e.to_string()))?, + transaction_input, signed_envelope_xdr: None, transaction_result_xdr: None, }; @@ -1870,6 +1906,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }); let relayer_model = RelayerRepoModel { @@ -2314,6 +2351,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2342,6 +2380,7 @@ mod tests { transaction_xdr: Some(unsigned_xdr.to_string()), fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2426,6 +2465,7 @@ mod tests { transaction_xdr: Some(signed_xdr.to_string()), fee_bump: Some(true), max_fee: Some(20000000), + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2455,6 +2495,7 @@ mod tests { transaction_xdr: Some(signed_xdr.clone()), fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2481,6 +2522,7 @@ mod tests { transaction_xdr: None, fee_bump: Some(true), max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2514,6 +2556,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2546,6 +2589,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2590,6 +2634,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2632,6 +2677,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2658,6 +2704,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2693,6 +2740,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2720,6 +2768,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2747,6 +2796,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -3211,6 +3261,7 @@ mod tests { transaction_xdr, fee_bump: None, max_fee: None, + signed_auth_entry: None, } } diff --git a/src/models/transaction/request/mod.rs b/src/models/transaction/request/mod.rs index a9564d02b..69f6a7fbb 100644 --- a/src/models/transaction/request/mod.rs +++ b/src/models/transaction/request/mod.rs @@ -52,13 +52,17 @@ impl NetworkTransactionRequest { /// Network-agnostic fee estimate request parameters for gasless transactions. /// Contains network-specific request parameters for fee estimation. /// The network type is inferred from the relayer's network configuration. +/// +/// For Stellar, supports both classic and Soroban gas abstraction: +/// - Classic: Pass operations or transaction_xdr with classic fee token (native/USDC:GA...) +/// - Soroban: Pass transaction_xdr containing InvokeHostFunction, user_address, and contract fee token (C...) #[derive(Debug, Deserialize, Serialize, PartialEq, ToSchema, Clone)] #[serde(untagged)] #[schema(as = SponsoredTransactionQuoteRequest)] pub enum SponsoredTransactionQuoteRequest { /// Solana-specific fee estimate request parameters Solana(SolanaFeeEstimateRequestParams), - /// Stellar-specific fee estimate request parameters + /// Stellar-specific fee estimate request parameters (classic and Soroban) Stellar(StellarFeeEstimateRequestParams), } @@ -74,13 +78,17 @@ impl SponsoredTransactionQuoteRequest { /// Network-agnostic prepare transaction request parameters for gasless transactions. /// Contains network-specific request parameters for preparing transactions with fee payments. /// The network type is inferred from the relayer's network configuration. +/// +/// For Stellar, supports both classic and Soroban gas abstraction: +/// - Classic: Pass operations or transaction_xdr with classic fee token +/// - Soroban: Pass transaction_xdr containing InvokeHostFunction, user_address, and contract fee token #[derive(Debug, Deserialize, Serialize, PartialEq, ToSchema, Clone)] #[serde(untagged)] #[schema(as = SponsoredTransactionBuildRequest)] pub enum SponsoredTransactionBuildRequest { /// Solana-specific prepare transaction request parameters Solana(SolanaPrepareTransactionRequestParams), - /// Stellar-specific prepare transaction request parameters + /// Stellar-specific prepare transaction request parameters (classic and Soroban) Stellar(StellarPrepareTransactionRequestParams), } diff --git a/src/models/transaction/request/stellar.rs b/src/models/transaction/request/stellar.rs index 4905c0281..cc20c67fc 100644 --- a/src/models/transaction/request/stellar.rs +++ b/src/models/transaction/request/stellar.rs @@ -15,7 +15,9 @@ pub struct StellarTransactionRequest { #[schema(nullable = true)] pub valid_until: Option, /// Pre-built transaction XDR (base64 encoded, signed or unsigned) - /// Mutually exclusive with operations field + /// Mutually exclusive with operations field. + /// For Soroban gas abstraction: submit the transaction XDR from sponsored/build response + /// with the user's signed auth entry updated inside. #[schema(nullable = true)] pub transaction_xdr: Option, /// Explicitly request fee-bump wrapper @@ -25,6 +27,12 @@ pub struct StellarTransactionRequest { /// Maximum fee in stroops (defaults to 0.1 XLM = 1,000,000 stroops) #[schema(nullable = true)] pub max_fee: Option, + /// Signed Soroban authorization entry (base64 encoded SorobanAuthorizationEntry XDR) + /// Used for Soroban gas abstraction: contains the user's signed auth entry from /build response. + /// When provided, transaction_xdr must also be provided (the FeeForwarder transaction from /build). + /// The relayer will inject this signed auth entry into the transaction before submitting. + #[schema(nullable = true)] + pub signed_auth_entry: Option, } impl StellarTransactionRequest { @@ -32,6 +40,8 @@ impl StellarTransactionRequest { /// - Only one input type allowed (operations XOR transaction_xdr) /// - If fee_bump is true, transaction_xdr must be provided /// - Operations mode cannot use fee_bump + /// - If signed_auth_entry is provided, transaction_xdr must also be provided + /// - signed_auth_entry and fee_bump are mutually exclusive pub fn validate(&self) -> Result<(), crate::models::ApiError> { use crate::models::ApiError; @@ -42,6 +52,7 @@ impl StellarTransactionRequest { .map(|ops| !ops.is_empty()) .unwrap_or(false); let has_xdr = self.transaction_xdr.is_some(); + let has_signed_auth_entry = self.signed_auth_entry.is_some(); match (has_operations, has_xdr) { (true, true) => { @@ -64,6 +75,20 @@ impl StellarTransactionRequest { )); } + // Validate signed_auth_entry usage (Soroban gas abstraction) + if has_signed_auth_entry { + if !has_xdr { + return Err(ApiError::BadRequest( + "signed_auth_entry requires transaction_xdr to be provided".to_string(), + )); + } + if self.fee_bump == Some(true) { + return Err(ApiError::BadRequest( + "Cannot use both signed_auth_entry and fee_bump".to_string(), + )); + } + } + Ok(()) } } @@ -107,6 +132,7 @@ mod tests { transaction_xdr: Some("AAAAA...".to_string()), fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let result = req.validate(); @@ -130,6 +156,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let result = req.validate(); @@ -157,6 +184,7 @@ mod tests { transaction_xdr: None, fee_bump: Some(true), max_fee: None, + signed_auth_entry: None, }; let result = req.validate(); @@ -180,6 +208,7 @@ mod tests { transaction_xdr: Some("AAAAA...".to_string()), fee_bump: Some(true), max_fee: Some(10000000), + signed_auth_entry: None, }; let result = req.validate(); @@ -203,6 +232,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let result = req.validate(); @@ -222,6 +252,7 @@ mod tests { transaction_xdr: Some("AAAAA...".to_string()), fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let result = req.validate(); @@ -241,6 +272,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; assert_eq!( @@ -254,6 +286,7 @@ mod tests { assert!(req.transaction_xdr.is_none()); assert!(req.fee_bump.is_none()); assert!(req.max_fee.is_none()); + assert!(req.signed_auth_entry.is_none()); } #[test] @@ -318,4 +351,95 @@ mod tests { // Validate should pass assert!(req.validate().is_ok()); } + + #[test] + fn test_validate_signed_auth_entry_with_xdr() { + // Soroban gas abstraction: signed_auth_entry with transaction_xdr is valid + let req = StellarTransactionRequest { + source_account: None, + network: "testnet".to_string(), + operations: None, + memo: None, + valid_until: None, + transaction_xdr: Some("AAAAA...".to_string()), + fee_bump: None, + max_fee: None, + signed_auth_entry: Some("BBBBB...".to_string()), + }; + + let result = req.validate(); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_signed_auth_entry_without_xdr() { + // signed_auth_entry without transaction_xdr should fail + let req = StellarTransactionRequest { + source_account: None, + network: "testnet".to_string(), + operations: Some(vec![OperationSpec::Payment { + destination: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string(), + amount: 1000000, + asset: crate::models::transaction::stellar::AssetSpec::Native, + }]), + memo: None, + valid_until: None, + transaction_xdr: None, + fee_bump: None, + max_fee: None, + signed_auth_entry: Some("BBBBB...".to_string()), + }; + + let result = req.validate(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("signed_auth_entry requires transaction_xdr")); + } + + #[test] + fn test_validate_signed_auth_entry_with_fee_bump() { + // signed_auth_entry with fee_bump should fail (mutually exclusive) + let req = StellarTransactionRequest { + source_account: None, + network: "testnet".to_string(), + operations: None, + memo: None, + valid_until: None, + transaction_xdr: Some("AAAAA...".to_string()), + fee_bump: Some(true), + max_fee: Some(10000000), + signed_auth_entry: Some("BBBBB...".to_string()), + }; + + let result = req.validate(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Cannot use both signed_auth_entry and fee_bump")); + } + + #[test] + fn test_serde_signed_auth_entry() { + // Test JSON deserialization with signed_auth_entry + let json = r#"{ + "network": "testnet", + "transaction_xdr": "AAAAAgAAAACige4lTdwSB/sto4SniEdJ2kOa2X65s5bqkd40J4DjSwAAAAEAAHAkAAAADwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAKKB7iVN3BIH+y2jhKeIR0naQ5rZfrmzluqR3jQngONLAAAAAAAAAAAAD0JAAAAAAAAAAAA=", + "signed_auth_entry": "AAAAAQAAAAEAAAAHYm9pbGVycz..." + }"#; + + let req: StellarTransactionRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.network, "testnet"); + assert!(req.transaction_xdr.is_some()); + assert!(req.signed_auth_entry.is_some()); + assert_eq!( + req.signed_auth_entry, + Some("AAAAAQAAAAEAAAAHYm9pbGVycz...".to_string()) + ); + + // Validate should pass + assert!(req.validate().is_ok()); + } } diff --git a/src/models/transaction/response.rs b/src/models/transaction/response.rs index e2bba2ec2..2b7e1a5d1 100644 --- a/src/models/transaction/response.rs +++ b/src/models/transaction/response.rs @@ -181,7 +181,7 @@ impl From for TransactionResponse { pub enum SponsoredTransactionQuoteResponse { /// Solana-specific fee estimate result Solana(SolanaFeeEstimateResult), - /// Stellar-specific fee estimate result + /// Stellar-specific fee estimate result (classic and Soroban) Stellar(StellarFeeEstimateResult), } @@ -193,7 +193,8 @@ pub enum SponsoredTransactionQuoteResponse { pub enum SponsoredTransactionBuildResponse { /// Solana-specific prepare transaction result Solana(SolanaPrepareTransactionResult), - /// Stellar-specific prepare transaction result + /// Stellar-specific prepare transaction result (classic and Soroban) + /// For Soroban: includes optional user_auth_entry, expiration_ledger Stellar(StellarPrepareTransactionResult), } diff --git a/src/models/transaction/stellar/conversion.rs b/src/models/transaction/stellar/conversion.rs index c210336e0..3e25c6fd1 100644 --- a/src/models/transaction/stellar/conversion.rs +++ b/src/models/transaction/stellar/conversion.rs @@ -110,7 +110,8 @@ impl TryFrom for Transaction { }) } crate::models::TransactionInput::UnsignedXdr(_) - | crate::models::TransactionInput::SignedXdr { .. } => { + | crate::models::TransactionInput::SignedXdr { .. } + | crate::models::TransactionInput::SorobanGasAbstraction { .. } => { // XDR inputs should not be converted to Transaction // The signer handles TransactionEnvelope XDR directly Err(SignerError::ConversionError( diff --git a/src/repositories/transaction/transaction_redis.rs b/src/repositories/transaction/transaction_redis.rs index 332b9b02b..bce9e52f1 100644 --- a/src/repositories/transaction/transaction_redis.rs +++ b/src/repositories/transaction/transaction_redis.rs @@ -1363,8 +1363,7 @@ impl TransactionRepository for RedisTransactionRepository { if cas_result == -1 { return Err(RepositoryError::NotFound(format!( - "Transaction with ID {} not found", - tx_id + "Transaction with ID {tx_id} not found" ))); } @@ -1382,8 +1381,7 @@ impl TransactionRepository for RedisTransactionRepository { continue; } return Err(RepositoryError::TransactionFailure(format!( - "Concurrent update conflict for transaction {}", - tx_id + "Concurrent update conflict for transaction {tx_id}" ))); } diff --git a/src/services/mod.rs b/src/services/mod.rs index 935323be5..7e5c6ac2e 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -20,6 +20,9 @@ pub use jupiter::*; pub mod stellar_dex; pub use stellar_dex::*; +pub mod stellar_fee_forwarder; +pub use stellar_fee_forwarder::*; + mod vault; pub use vault::*; diff --git a/src/services/signer/stellar/aws_kms_signer.rs b/src/services/signer/stellar/aws_kms_signer.rs index cf1a16c49..5fc433ed5 100644 --- a/src/services/signer/stellar/aws_kms_signer.rs +++ b/src/services/signer/stellar/aws_kms_signer.rs @@ -17,7 +17,9 @@ use crate::{ use async_trait::async_trait; use sha2::{Digest, Sha256}; use soroban_rs::xdr::{ - DecoratedSignature, Hash, Limits, ReadXdr, Signature, SignatureHint, Transaction, + DecoratedSignature, Hash, HashIdPreimage, HashIdPreimageSorobanAuthorization, Limits, ReadXdr, + ScBytes, ScMap, ScMapEntry, ScSymbol, ScVal, ScVec, Signature, SignatureHint, + SorobanAddressCredentials, SorobanAuthorizationEntry, SorobanCredentials, Transaction, TransactionEnvelope, WriteXdr, }; use tracing::debug; @@ -81,7 +83,8 @@ impl Signer for AwsKmsSigner { .await? } crate::models::TransactionInput::UnsignedXdr(xdr) - | crate::models::TransactionInput::SignedXdr { xdr, .. } => { + | crate::models::TransactionInput::SignedXdr { xdr, .. } + | crate::models::TransactionInput::SorobanGasAbstraction { xdr, .. } => { // Parse the XDR envelope and sign let envelope = TransactionEnvelope::from_xdr_base64(xdr, Limits::none()).map_err(|e| { diff --git a/src/services/signer/stellar/google_cloud_kms_signer.rs b/src/services/signer/stellar/google_cloud_kms_signer.rs index 5f7aa58de..5f38d3bf9 100644 --- a/src/services/signer/stellar/google_cloud_kms_signer.rs +++ b/src/services/signer/stellar/google_cloud_kms_signer.rs @@ -90,7 +90,8 @@ impl Signer .await? } crate::models::TransactionInput::UnsignedXdr(xdr) - | crate::models::TransactionInput::SignedXdr { xdr, .. } => { + | crate::models::TransactionInput::SignedXdr { xdr, .. } + | crate::models::TransactionInput::SorobanGasAbstraction { xdr, .. } => { // Parse the XDR envelope and sign let envelope = TransactionEnvelope::from_xdr_base64(xdr, Limits::none()).map_err(|e| { diff --git a/src/services/signer/stellar/local_signer.rs b/src/services/signer/stellar/local_signer.rs index cb4cf25fd..5c0c3ec5f 100644 --- a/src/services/signer/stellar/local_signer.rs +++ b/src/services/signer/stellar/local_signer.rs @@ -32,8 +32,7 @@ use eyre::Result; use sha2::{Digest, Sha256}; use soroban_rs::xdr::{ DecoratedSignature, Hash, Limits, ReadXdr, Signature, SignatureHint, Transaction, - TransactionEnvelope, TransactionSignaturePayload, TransactionSignaturePayloadTaggedTransaction, - Uint256, VecM, WriteXdr, + TransactionEnvelope, Uint256, WriteXdr, }; use tracing::info; @@ -132,7 +131,9 @@ impl Signer for LocalSigner { SignerError::SigningError(format!("failed to sign transaction: {e}")) })? } - TransactionInput::UnsignedXdr(xdr) | TransactionInput::SignedXdr { xdr, .. } => { + TransactionInput::UnsignedXdr(xdr) + | TransactionInput::SignedXdr { xdr, .. } + | TransactionInput::SorobanGasAbstraction { xdr, .. } => { // Parse the XDR envelope and sign let envelope = TransactionEnvelope::from_xdr_base64(xdr, Limits::none()) .map_err(|e| SignerError::SigningError(format!("invalid envelope XDR: {e}")))?; diff --git a/src/services/signer/stellar/turnkey_signer.rs b/src/services/signer/stellar/turnkey_signer.rs index 4328cf71b..7d8851066 100644 --- a/src/services/signer/stellar/turnkey_signer.rs +++ b/src/services/signer/stellar/turnkey_signer.rs @@ -232,7 +232,9 @@ impl Signer for TurnkeySigner { self.sign_transaction_directly(&transaction, &network_id) .await? } - TransactionInput::UnsignedXdr(xdr) | TransactionInput::SignedXdr { xdr, .. } => { + TransactionInput::UnsignedXdr(xdr) + | TransactionInput::SignedXdr { xdr, .. } + | TransactionInput::SorobanGasAbstraction { xdr, .. } => { let envelope = TransactionEnvelope::from_xdr_base64(xdr, Limits::none()).map_err(|e| { SignerError::SigningError(format!( diff --git a/src/services/stellar_dex/mod.rs b/src/services/stellar_dex/mod.rs index 4cbacb0be..c2f0f7d27 100644 --- a/src/services/stellar_dex/mod.rs +++ b/src/services/stellar_dex/mod.rs @@ -1,11 +1,13 @@ //! Stellar DEX service module //! Provides quote conversion services for Stellar tokens to XLM -//! Supports native Stellar paths API and optional Soroswap integration +//! Supports native Stellar paths API and Soroswap integration for Soroban tokens mod order_book_service; +mod soroswap_service; mod stellar_dex_service; pub use order_book_service::OrderBookService; +pub use soroswap_service::SoroswapService; pub use stellar_dex_service::{DexServiceWrapper, StellarDexService}; use async_trait::async_trait; diff --git a/src/services/stellar_dex/soroswap_service.rs b/src/services/stellar_dex/soroswap_service.rs new file mode 100644 index 000000000..40bf536bc --- /dev/null +++ b/src/services/stellar_dex/soroswap_service.rs @@ -0,0 +1,1623 @@ +//! Soroswap DEX Service implementation +//! +//! Uses Soroswap AMM router contract for token swaps on Soroban. +//! This service handles swaps between Soroban token contracts (C... addresses) and XLM. +//! +//! The router contract provides `get_amounts_out` for quotes and +//! `swap_exact_tokens_for_tokens` for executing swaps. + +use super::{ + AssetType, PathStep, StellarDexServiceError, StellarDexServiceTrait, StellarQuoteResponse, + SwapExecutionResult, SwapTransactionParams, +}; +use crate::constants::STELLAR_DEFAULT_TRANSACTION_FEE; +use crate::domain::relayer::string_to_muxed_account; +use crate::domain::transaction::stellar::utils::{parse_account_id, parse_contract_address}; +use crate::services::provider::StellarProviderTrait; +use async_trait::async_trait; +use chrono::{Duration as ChronoDuration, Utc}; +use soroban_rs::xdr::{ + ContractId, HostFunction, Int128Parts, InvokeContractArgs, InvokeHostFunctionOp, Limits, Memo, + Operation, OperationBody, Preconditions, ScAddress, ScSymbol, ScVal, ScVec, SequenceNumber, + TimeBounds, TimePoint, Transaction, TransactionEnvelope, TransactionExt, TransactionV1Envelope, + VecM, WriteXdr, +}; +use std::collections::HashSet; +use std::sync::Arc; +use tracing::{debug, info, warn}; + +/// Transaction validity window in minutes +const TRANSACTION_VALIDITY_MINUTES: i64 = 5; + +/// Soroswap AMM DEX service for Soroban token swaps +/// +/// This service uses Soroswap's router contract to: +/// - Get quotes by simulating `get_amounts_out` +/// - Execute swaps via `swap_exact_tokens_for_tokens` +pub struct SoroswapService

+where + P: StellarProviderTrait + Send + Sync + 'static, +{ + /// Soroswap router contract address + router_address: String, + /// Soroswap factory contract address (required for get_amounts_out) + factory_address: String, + /// Native XLM wrapper token address + native_wrapper_address: String, + /// Stellar provider for contract calls + provider: Arc

, + /// Network passphrase for signing (used for swap execution) + #[allow(dead_code)] + network_passphrase: String, +} + +impl

SoroswapService

+where + P: StellarProviderTrait + Send + Sync + 'static, +{ + /// Create a new SoroswapService instance + /// + /// # Arguments + /// + /// * `router_address` - Soroswap router contract address + /// * `factory_address` - Soroswap factory contract address (required for get_amounts_out) + /// * `native_wrapper_address` - Native XLM wrapper token address + /// * `provider` - Stellar provider for contract calls + /// * `network_passphrase` - Network passphrase + pub fn new( + router_address: String, + factory_address: String, + native_wrapper_address: String, + provider: Arc

, + network_passphrase: String, + ) -> Self { + Self { + router_address, + factory_address, + native_wrapper_address, + provider, + network_passphrase, + } + } + + /// Parse a Soroban contract address (C...) to ScAddress + fn parse_contract_to_sc_address(address: &str) -> Result { + let hash = parse_contract_address(address).map_err(|e| { + StellarDexServiceError::InvalidAssetIdentifier(format!( + "Invalid Soroban contract address '{address}': {e}" + )) + })?; + + Ok(ScAddress::Contract(ContractId(hash))) + } + + /// Build a Vec path for router calls + fn build_path( + &self, + from_token: &str, + to_token: &str, + ) -> Result { + let from_addr = Self::parse_contract_to_sc_address(from_token)?; + let to_addr = Self::parse_contract_to_sc_address(to_token)?; + + // Simple direct path: [from_token, to_token] + let path_vec: ScVec = vec![ScVal::Address(from_addr), ScVal::Address(to_addr)] + .try_into() + .map_err(|_| { + StellarDexServiceError::UnknownError("Failed to create path vector".to_string()) + })?; + + Ok(ScVal::Vec(Some(path_vec))) + } + + /// Convert i128 to ScVal::I128 + fn i128_to_scval(amount: i128) -> ScVal { + let hi = (amount >> 64) as i64; + let lo = amount as u64; + ScVal::I128(Int128Parts { hi, lo }) + } + + /// Extract i128 from ScVal::I128 + fn scval_to_i128(val: &ScVal) -> Result { + match val { + ScVal::I128(parts) => { + let result = ((parts.hi as i128) << 64) | (parts.lo as i128); + Ok(result) + } + _ => Err(StellarDexServiceError::UnknownError( + "Expected I128 value from router".to_string(), + )), + } + } + + /// Extract Vec from ScVal::Vec of I128s + fn scval_to_amounts_vec(val: &ScVal) -> Result, StellarDexServiceError> { + match val { + ScVal::Vec(Some(sc_vec)) => { + let mut amounts = Vec::new(); + for item in sc_vec.iter() { + amounts.push(Self::scval_to_i128(item)?); + } + Ok(amounts) + } + _ => Err(StellarDexServiceError::UnknownError( + "Expected Vec of I128 values from router".to_string(), + )), + } + } + + /// Call router.get_amounts_out to get quote + /// + /// Returns the expected output amounts for each step in the path + /// Soroswap's get_amounts_out requires: (factory_address, amount_in, path) + async fn call_get_amounts_out( + &self, + amount_in: i128, + path: ScVal, + ) -> Result, StellarDexServiceError> { + let function_name = ScSymbol::try_from("get_amounts_out").map_err(|_| { + StellarDexServiceError::UnknownError("Failed to create function symbol".to_string()) + })?; + + // Soroswap's get_amounts_out requires factory address as first argument + let factory_addr = Self::parse_contract_to_sc_address(&self.factory_address)?; + let args = vec![ + ScVal::Address(factory_addr), + Self::i128_to_scval(amount_in), + path, + ]; + + debug!( + router = %self.router_address, + factory = %self.factory_address, + amount_in = amount_in, + "Calling Soroswap router get_amounts_out" + ); + + let result = self + .provider + .call_contract(&self.router_address, &function_name, args) + .await + .map_err(|e| StellarDexServiceError::ApiError { + message: format!("Soroswap router call failed: {e}"), + })?; + + Self::scval_to_amounts_vec(&result) + } + + /// Parse a Stellar account address (G...) to ScAddress::Account + fn parse_account_to_sc_address(address: &str) -> Result { + let account_id = parse_account_id(address).map_err(|e| { + StellarDexServiceError::InvalidAssetIdentifier(format!( + "Invalid Stellar account address '{address}': {e}" + )) + })?; + Ok(ScAddress::Account(account_id)) + } + + /// Build the Soroswap router swap transaction XDR (unsigned) + /// + /// Creates an `InvokeHostFunction` transaction that calls the Soroswap router's + /// `swap_exact_tokens_for_tokens` function. The transaction is returned as unsigned + /// base64-encoded XDR with placeholder sequence number (0). The transaction pipeline + /// will handle simulation (to get resources, footprint, and auth entries), sequence + /// number assignment, fee calculation, signing, and submission. + /// + /// Soroswap router function signature: + /// ```text + /// swap_exact_tokens_for_tokens( + /// amount_in: i128, + /// amount_out_min: i128, + /// path: Vec

, + /// to: Address, + /// deadline: u64 + /// ) -> Vec + /// ``` + fn build_swap_transaction_xdr( + &self, + params: &SwapTransactionParams, + quote: &StellarQuoteResponse, + ) -> Result { + // Step 1: Parse source account to MuxedAccount (for transaction source) + let source_account = string_to_muxed_account(¶ms.source_account).map_err(|e| { + StellarDexServiceError::InvalidAssetIdentifier(format!("Invalid source account: {e}")) + })?; + + // Step 2: Parse source account to ScAddress (for the `to` parameter — relayer swaps to itself) + let to_address = Self::parse_account_to_sc_address(¶ms.source_account)?; + + // Step 3: Calculate amount_out_min with slippage protection + // Formula: out_amount * (10000 - slippage_bps) / 10000 + let out_amount = quote.out_amount as u128; + let slippage_bps = quote.slippage_bps as u128; + let basis = 10000u128; + + let amount_out_min_u128 = out_amount + .checked_mul(basis.saturating_sub(slippage_bps)) + .ok_or_else(|| { + StellarDexServiceError::UnknownError( + "Overflow calculating minimum output amount".to_string(), + ) + })? + .checked_div(basis) + .ok_or_else(|| StellarDexServiceError::UnknownError("Division error".to_string()))?; + + // Ensure we don't request 0 if the quote was non-zero + let amount_out_min = if amount_out_min_u128 == 0 && out_amount > 0 { + 1i128 + } else { + amount_out_min_u128 as i128 + }; + + // Step 4: Resolve token addresses (replace "native" with wrapper contract address) + let from_token = if params.source_asset == "native" || params.source_asset.is_empty() { + self.native_wrapper_address.clone() + } else { + params.source_asset.clone() + }; + + let to_token = + if params.destination_asset == "native" || params.destination_asset.is_empty() { + self.native_wrapper_address.clone() + } else { + params.destination_asset.clone() + }; + + // Step 5: Build the path as Vec + let path = self.build_path(&from_token, &to_token)?; + + // Step 6: Calculate deadline (Unix timestamp, now + validity window) + let now = Utc::now(); + let deadline = now + ChronoDuration::minutes(TRANSACTION_VALIDITY_MINUTES); + let deadline_timestamp = deadline.timestamp() as u64; + + // Step 7: Build router contract invocation args + let router_addr = Self::parse_contract_to_sc_address(&self.router_address)?; + let function_name = ScSymbol::try_from("swap_exact_tokens_for_tokens").map_err(|_| { + StellarDexServiceError::UnknownError( + "Failed to create swap function symbol".to_string(), + ) + })?; + + let args: VecM = vec![ + Self::i128_to_scval(params.amount as i128), // amount_in + Self::i128_to_scval(amount_out_min), // amount_out_min + path, // path: Vec
+ ScVal::Address(to_address), // to: relayer address + ScVal::U64(deadline_timestamp), // deadline: Unix timestamp + ] + .try_into() + .map_err(|_| { + StellarDexServiceError::UnknownError("Failed to create swap function args".to_string()) + })?; + + // Step 8: Create InvokeHostFunction operation + let host_function = HostFunction::InvokeContract(InvokeContractArgs { + contract_address: router_addr, + function_name, + args, + }); + + let invoke_op = Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp { + host_function, + auth: VecM::default(), // Empty — simulation will populate auth entries + }), + }; + + // Step 9: Build time bounds + let time_bounds = TimeBounds { + min_time: TimePoint(0), + max_time: TimePoint(deadline_timestamp), + }; + + // Step 10: Build Transaction with placeholder sequence and fee + let transaction = Transaction { + source_account, + fee: STELLAR_DEFAULT_TRANSACTION_FEE, + seq_num: SequenceNumber(0), // Placeholder — pipeline updates + cond: Preconditions::Time(time_bounds), + memo: Memo::None, + operations: vec![invoke_op].try_into().map_err(|_| { + StellarDexServiceError::UnknownError( + "Failed to create operations vector".to_string(), + ) + })?, + ext: TransactionExt::V0, + }; + + // Step 11: Create TransactionEnvelope and serialize + let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx: transaction, + signatures: VecM::default(), // Unsigned + }); + + envelope.to_xdr_base64(Limits::none()).map_err(|e| { + StellarDexServiceError::UnknownError(format!( + "Failed to serialize transaction to XDR: {e}" + )) + }) + } +} + +#[async_trait] +impl

StellarDexServiceTrait for SoroswapService

+where + P: StellarProviderTrait + Send + Sync + 'static, +{ + fn supported_asset_types(&self) -> HashSet { + // Soroswap supports Soroban contract tokens and Native XLM (via wrapper) + HashSet::from([AssetType::Native, AssetType::Contract]) + } + + fn can_handle_asset(&self, asset_id: &str) -> bool { + // Handle native XLM (will use wrapper) + if asset_id == "native" || asset_id.is_empty() { + return true; + } + + // Handle Soroban contract tokens (C... format, 56 chars) + if asset_id.starts_with('C') + && asset_id.len() == 56 + && !asset_id.contains(':') + && stellar_strkey::Contract::from_string(asset_id).is_ok() + { + return true; + } + + false + } + + async fn get_token_to_xlm_quote( + &self, + asset_id: &str, + amount: u64, + slippage: f32, + _asset_decimals: Option, + ) -> Result { + // For native XLM, return 1:1 + if asset_id == "native" || asset_id.is_empty() { + return Ok(StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: "native".to_string(), + in_amount: amount, + out_amount: amount, + price_impact_pct: 0.0, + slippage_bps: (slippage * 100.0) as u32, + path: None, + }); + } + + // Build path: [token, native_wrapper] + let path = self.build_path(asset_id, &self.native_wrapper_address)?; + + // Call router to get quote + let amounts = self.call_get_amounts_out(amount as i128, path).await?; + + // Last amount is the output + let out_amount = amounts + .last() + .copied() + .ok_or_else(|| StellarDexServiceError::NoPathFound)?; + + if out_amount <= 0 { + return Err(StellarDexServiceError::NoPathFound); + } + + // Safe conversion from i128 to u64 - we already checked out_amount > 0 above + let out_amount_u64 = u64::try_from(out_amount).map_err(|_| { + StellarDexServiceError::UnknownError(format!( + "Output amount {out_amount} exceeds u64::MAX" + )) + })?; + + debug!( + asset = %asset_id, + in_amount = amount, + out_amount = out_amount_u64, + "Soroswap quote: token -> XLM" + ); + + Ok(StellarQuoteResponse { + input_asset: asset_id.to_string(), + output_asset: "native".to_string(), + in_amount: amount, + out_amount: out_amount_u64, + price_impact_pct: 0.0, + slippage_bps: (slippage * 100.0) as u32, + path: Some(vec![ + PathStep { + asset_code: Some(asset_id.to_string()), + asset_issuer: None, + amount, + }, + PathStep { + asset_code: Some("native".to_string()), + asset_issuer: None, + amount: out_amount_u64, + }, + ]), + }) + } + + async fn get_xlm_to_token_quote( + &self, + asset_id: &str, + amount: u64, + slippage: f32, + _asset_decimals: Option, + ) -> Result { + // For native XLM, return 1:1 + if asset_id == "native" || asset_id.is_empty() { + return Ok(StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: "native".to_string(), + in_amount: amount, + out_amount: amount, + price_impact_pct: 0.0, + slippage_bps: (slippage * 100.0) as u32, + path: None, + }); + } + + // Build path: [native_wrapper, token] + let path = self.build_path(&self.native_wrapper_address, asset_id)?; + + // Call router to get quote + let amounts = self.call_get_amounts_out(amount as i128, path).await?; + + // Last amount is the output + let out_amount = amounts + .last() + .copied() + .ok_or_else(|| StellarDexServiceError::NoPathFound)?; + + if out_amount <= 0 { + return Err(StellarDexServiceError::NoPathFound); + } + + // Safe conversion from i128 to u64 - we already checked out_amount > 0 above + let out_amount_u64 = u64::try_from(out_amount).map_err(|_| { + StellarDexServiceError::UnknownError(format!( + "Output amount {out_amount} exceeds u64::MAX" + )) + })?; + + // Calculate price impact (simplified - assumes 1:1 expected ratio) + // TODO: Use pool reserves for accurate price impact calculation + let price_impact = if amount > 0 && out_amount_u64 > 0 { + let expected_ratio = 1.0; + let actual_ratio = out_amount_u64 as f64 / amount as f64; + ((expected_ratio - actual_ratio).abs() / expected_ratio * 100.0).min(100.0) + } else { + 0.0 + }; + + debug!( + asset = %asset_id, + in_amount = amount, + out_amount = out_amount_u64, + "Soroswap quote: XLM -> token" + ); + + Ok(StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: asset_id.to_string(), + in_amount: amount, + out_amount: out_amount_u64, + price_impact_pct: price_impact, + slippage_bps: (slippage * 100.0) as u32, + path: Some(vec![ + PathStep { + asset_code: Some("native".to_string()), + asset_issuer: None, + amount, + }, + PathStep { + asset_code: Some(asset_id.to_string()), + asset_issuer: None, + amount: out_amount_u64, + }, + ]), + }) + } + + async fn prepare_swap_transaction( + &self, + params: SwapTransactionParams, + ) -> Result<(String, StellarQuoteResponse), StellarDexServiceError> { + // Get a quote for the swap + let quote = if params.destination_asset == "native" { + self.get_token_to_xlm_quote( + ¶ms.source_asset, + params.amount, + params.slippage_percent, + params.source_asset_decimals, + ) + .await? + } else if params.source_asset == "native" { + self.get_xlm_to_token_quote( + ¶ms.destination_asset, + params.amount, + params.slippage_percent, + params.destination_asset_decimals, + ) + .await? + } else { + return Err(StellarDexServiceError::InvalidAssetIdentifier( + "Soroswap currently only supports swaps involving native XLM".to_string(), + )); + }; + + info!( + "Preparing Soroswap swap transaction: {} {} -> {} (min receive: {})", + params.amount, params.source_asset, params.destination_asset, quote.out_amount + ); + + // Build the unsigned swap transaction XDR + let xdr = self.build_swap_transaction_xdr(¶ms, "e)?; + + info!( + "Successfully prepared Soroswap swap transaction XDR ({} bytes)", + xdr.len() + ); + + Ok((xdr, quote)) + } + + /// Swap execution is not yet implemented. + /// + /// Required by [`StellarDexServiceTrait`]. `params` is intentionally unused and will be + /// used when building and submitting the Soroswap swap transaction. + async fn execute_swap( + &self, + _params: SwapTransactionParams, + ) -> Result { + warn!("Soroswap execute_swap is not yet implemented"); + + Err(StellarDexServiceError::UnknownError( + "Soroswap swap execution is not yet implemented".to_string(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constants::STELLAR_SOROSWAP_MAINNET_NATIVE_WRAPPER; + use crate::services::provider::MockStellarProviderTrait; + use futures::FutureExt; + use soroban_rs::xdr::ReadXdr; + + const TEST_NATIVE_WRAPPER: &str = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + + fn create_mock_provider() -> Arc { + Arc::new(MockStellarProviderTrait::new()) + } + + fn create_test_service( + provider: Arc, + ) -> SoroswapService { + SoroswapService::new( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), // router + "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA".to_string(), // factory + TEST_NATIVE_WRAPPER.to_string(), + provider, + "Test SDF Network ; September 2015".to_string(), + ) + } + + // ==================== Constructor Tests ==================== + + #[test] + fn test_new_stores_provided_native_wrapper() { + let provider = create_mock_provider(); + let service = create_test_service(provider); + assert_eq!(service.native_wrapper_address, TEST_NATIVE_WRAPPER); + } + + #[test] + fn test_new_with_mainnet_native_wrapper() { + let provider = create_mock_provider(); + let service = SoroswapService::new( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA".to_string(), + STELLAR_SOROSWAP_MAINNET_NATIVE_WRAPPER.to_string(), + provider, + "Public Global Stellar Network ; September 2015".to_string(), + ); + assert_eq!( + service.native_wrapper_address, + STELLAR_SOROSWAP_MAINNET_NATIVE_WRAPPER + ); + } + + #[test] + fn test_new_with_custom_native_wrapper() { + let provider = create_mock_provider(); + let custom_wrapper = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M".to_string(); + let service = SoroswapService::new( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA".to_string(), + custom_wrapper.clone(), + provider, + "Test SDF Network ; September 2015".to_string(), + ); + assert_eq!(service.native_wrapper_address, custom_wrapper); + } + + // ==================== parse_contract_to_sc_address Tests ==================== + + #[test] + fn test_parse_contract_to_sc_address_valid() { + let addr = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + let result = + SoroswapService::::parse_contract_to_sc_address(addr); + assert!(result.is_ok()); + match result.unwrap() { + ScAddress::Contract(_) => {} + _ => panic!("Expected Contract address"), + } + } + + #[test] + fn test_parse_contract_to_sc_address_invalid_format() { + let addr = "INVALID_ADDRESS"; + let result = + SoroswapService::::parse_contract_to_sc_address(addr); + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::InvalidAssetIdentifier(msg) => { + assert!(msg.contains("Invalid Soroban contract address")); + } + _ => panic!("Expected InvalidAssetIdentifier error"), + } + } + + #[test] + fn test_parse_contract_to_sc_address_stellar_account_not_contract() { + // A valid Stellar account address (G...) but not a contract (C...) + let addr = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + let result = + SoroswapService::::parse_contract_to_sc_address(addr); + assert!(result.is_err()); + } + + // ==================== can_handle_asset Tests ==================== + + #[test] + fn test_can_handle_asset_native() { + let provider = create_mock_provider(); + let service = create_test_service(provider); + assert!(service.can_handle_asset("native")); + } + + #[test] + fn test_can_handle_asset_empty_string() { + let provider = create_mock_provider(); + let service = create_test_service(provider); + assert!(service.can_handle_asset("")); + } + + #[test] + fn test_can_handle_asset_valid_contract() { + let provider = create_mock_provider(); + let service = create_test_service(provider); + let contract_addr = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + assert!(service.can_handle_asset(contract_addr)); + } + + #[test] + fn test_cannot_handle_classic_asset() { + let provider = create_mock_provider(); + let service = create_test_service(provider); + let classic_asset = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + assert!(!service.can_handle_asset(classic_asset)); + } + + #[test] + fn test_cannot_handle_short_address() { + let provider = create_mock_provider(); + let service = create_test_service(provider); + assert!(!service.can_handle_asset("CSHORT")); + } + + #[test] + fn test_cannot_handle_non_c_prefix() { + let provider = create_mock_provider(); + let service = create_test_service(provider); + // Stellar account address (G prefix) + let addr = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + assert!(!service.can_handle_asset(addr)); + } + + #[test] + fn test_cannot_handle_invalid_contract_checksum() { + let provider = create_mock_provider(); + let service = create_test_service(provider); + // Valid format but invalid checksum + let invalid_addr = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + assert!(!service.can_handle_asset(invalid_addr)); + } + + // ==================== supported_asset_types Tests ==================== + + #[test] + fn test_supported_asset_types() { + let provider = create_mock_provider(); + let service = create_test_service(provider); + let types = service.supported_asset_types(); + assert!(types.contains(&AssetType::Native)); + assert!(types.contains(&AssetType::Contract)); + assert_eq!(types.len(), 2); + } + + // ==================== i128 Conversion Tests ==================== + + #[test] + fn test_i128_to_scval_and_back_positive() { + let original: i128 = 1_000_000_000; + let scval = SoroswapService::::i128_to_scval(original); + let recovered = SoroswapService::::scval_to_i128(&scval).unwrap(); + assert_eq!(original, recovered); + } + + #[test] + fn test_i128_to_scval_and_back_zero() { + let original: i128 = 0; + let scval = SoroswapService::::i128_to_scval(original); + let recovered = SoroswapService::::scval_to_i128(&scval).unwrap(); + assert_eq!(original, recovered); + } + + #[test] + fn test_i128_to_scval_and_back_negative() { + let original: i128 = -1_000_000_000; + let scval = SoroswapService::::i128_to_scval(original); + let recovered = SoroswapService::::scval_to_i128(&scval).unwrap(); + assert_eq!(original, recovered); + } + + #[test] + fn test_i128_to_scval_and_back_large_positive() { + let original: i128 = i128::MAX / 2; + let scval = SoroswapService::::i128_to_scval(original); + let recovered = SoroswapService::::scval_to_i128(&scval).unwrap(); + assert_eq!(original, recovered); + } + + #[test] + fn test_i128_to_scval_and_back_large_negative() { + let original: i128 = i128::MIN / 2; + let scval = SoroswapService::::i128_to_scval(original); + let recovered = SoroswapService::::scval_to_i128(&scval).unwrap(); + assert_eq!(original, recovered); + } + + #[test] + fn test_scval_to_i128_wrong_type() { + let scval = ScVal::Bool(true); + let result = SoroswapService::::scval_to_i128(&scval); + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::UnknownError(msg) => { + assert!(msg.contains("Expected I128 value")); + } + _ => panic!("Expected UnknownError"), + } + } + + // ==================== scval_to_amounts_vec Tests ==================== + + #[test] + fn test_scval_to_amounts_vec_valid() { + let amounts: Vec = vec![100, 200, 300]; + let sc_vals: Vec = amounts + .iter() + .map(|&a| SoroswapService::::i128_to_scval(a)) + .collect(); + let sc_vec: ScVec = sc_vals.try_into().unwrap(); + let scval = ScVal::Vec(Some(sc_vec)); + + let result = + SoroswapService::::scval_to_amounts_vec(&scval).unwrap(); + assert_eq!(result, vec![100, 200, 300]); + } + + #[test] + fn test_scval_to_amounts_vec_empty() { + let sc_vec: ScVec = vec![].try_into().unwrap(); + let scval = ScVal::Vec(Some(sc_vec)); + + let result = + SoroswapService::::scval_to_amounts_vec(&scval).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_scval_to_amounts_vec_wrong_type() { + let scval = ScVal::Bool(true); + let result = SoroswapService::::scval_to_amounts_vec(&scval); + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::UnknownError(msg) => { + assert!(msg.contains("Expected Vec of I128 values")); + } + _ => panic!("Expected UnknownError"), + } + } + + #[test] + fn test_scval_to_amounts_vec_none() { + let scval = ScVal::Vec(None); + let result = SoroswapService::::scval_to_amounts_vec(&scval); + assert!(result.is_err()); + } + + #[test] + fn test_scval_to_amounts_vec_mixed_types() { + // Vec containing a non-I128 value + let sc_vec: ScVec = vec![ScVal::Bool(true)].try_into().unwrap(); + let scval = ScVal::Vec(Some(sc_vec)); + + let result = SoroswapService::::scval_to_amounts_vec(&scval); + assert!(result.is_err()); + } + + // ==================== build_path Tests ==================== + + #[test] + fn test_build_path_valid() { + let provider = create_mock_provider(); + let service = create_test_service(provider); + let from = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + let to = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; + + let result = service.build_path(from, to); + assert!(result.is_ok()); + match result.unwrap() { + ScVal::Vec(Some(vec)) => { + assert_eq!(vec.len(), 2); + } + _ => panic!("Expected Vec"), + } + } + + #[test] + fn test_build_path_invalid_from() { + let provider = create_mock_provider(); + let service = create_test_service(provider); + let result = service.build_path( + "INVALID", + "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", + ); + assert!(result.is_err()); + } + + #[test] + fn test_build_path_invalid_to() { + let provider = create_mock_provider(); + let service = create_test_service(provider); + let result = service.build_path( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + "INVALID", + ); + assert!(result.is_err()); + } + + // ==================== Async Quote Tests ==================== + + #[tokio::test] + async fn test_get_token_to_xlm_quote_native_returns_1_to_1() { + let provider = create_mock_provider(); + let service = create_test_service(provider); + + let quote = service + .get_token_to_xlm_quote("native", 1_000_000, 0.5, None) + .await + .unwrap(); + + assert_eq!(quote.input_asset, "native"); + assert_eq!(quote.output_asset, "native"); + assert_eq!(quote.in_amount, 1_000_000); + assert_eq!(quote.out_amount, 1_000_000); + assert_eq!(quote.price_impact_pct, 0.0); + assert_eq!(quote.slippage_bps, 50); + assert!(quote.path.is_none()); + } + + #[tokio::test] + async fn test_get_token_to_xlm_quote_empty_returns_1_to_1() { + let provider = create_mock_provider(); + let service = create_test_service(provider); + + let quote = service + .get_token_to_xlm_quote("", 1_000_000, 1.0, None) + .await + .unwrap(); + + assert_eq!(quote.input_asset, "native"); + assert_eq!(quote.output_asset, "native"); + assert_eq!(quote.in_amount, quote.out_amount); + } + + #[tokio::test] + async fn test_get_xlm_to_token_quote_native_returns_1_to_1() { + let provider = create_mock_provider(); + let service = create_test_service(provider); + + let quote = service + .get_xlm_to_token_quote("native", 1_000_000, 0.5, None) + .await + .unwrap(); + + assert_eq!(quote.input_asset, "native"); + assert_eq!(quote.output_asset, "native"); + assert_eq!(quote.in_amount, 1_000_000); + assert_eq!(quote.out_amount, 1_000_000); + } + + #[tokio::test] + async fn test_get_xlm_to_token_quote_empty_returns_1_to_1() { + let provider = create_mock_provider(); + let service = create_test_service(provider); + + let quote = service + .get_xlm_to_token_quote("", 500_000, 0.25, None) + .await + .unwrap(); + + assert_eq!(quote.input_asset, "native"); + assert_eq!(quote.output_asset, "native"); + assert_eq!(quote.slippage_bps, 25); + } + + #[tokio::test] + async fn test_get_token_to_xlm_quote_with_mock_provider() { + let mut mock = MockStellarProviderTrait::new(); + + // Build expected output - amounts vec with input and output + let amounts: Vec = vec![1_000_000, 950_000]; + let sc_vals: Vec = amounts + .iter() + .map(|&a| SoroswapService::::i128_to_scval(a)) + .collect(); + let sc_vec: ScVec = sc_vals.try_into().unwrap(); + let result_scval = ScVal::Vec(Some(sc_vec)); + + mock.expect_call_contract().returning(move |_, _, _| { + let result = result_scval.clone(); + async move { Ok(result) }.boxed() + }); + + let provider = Arc::new(mock); + let service = create_test_service(provider); + + let quote = service + .get_token_to_xlm_quote( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + 1_000_000, + 0.5, + None, + ) + .await + .unwrap(); + + assert_eq!(quote.in_amount, 1_000_000); + assert_eq!(quote.out_amount, 950_000); + assert_eq!(quote.output_asset, "native"); + assert!(quote.path.is_some()); + assert_eq!(quote.path.as_ref().unwrap().len(), 2); + } + + #[tokio::test] + async fn test_get_xlm_to_token_quote_with_mock_provider() { + let mut mock = MockStellarProviderTrait::new(); + + let amounts: Vec = vec![1_000_000, 1_050_000]; + let sc_vals: Vec = amounts + .iter() + .map(|&a| SoroswapService::::i128_to_scval(a)) + .collect(); + let sc_vec: ScVec = sc_vals.try_into().unwrap(); + let result_scval = ScVal::Vec(Some(sc_vec)); + + mock.expect_call_contract().returning(move |_, _, _| { + let result = result_scval.clone(); + async move { Ok(result) }.boxed() + }); + + let provider = Arc::new(mock); + let service = create_test_service(provider); + + let quote = service + .get_xlm_to_token_quote( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + 1_000_000, + 0.5, + None, + ) + .await + .unwrap(); + + assert_eq!(quote.in_amount, 1_000_000); + assert_eq!(quote.out_amount, 1_050_000); + assert_eq!(quote.input_asset, "native"); + } + + #[tokio::test] + async fn test_get_token_to_xlm_quote_empty_amounts_returns_no_path() { + let mut mock = MockStellarProviderTrait::new(); + + // Return empty amounts vec + let sc_vec: ScVec = vec![].try_into().unwrap(); + let result_scval = ScVal::Vec(Some(sc_vec)); + + mock.expect_call_contract().returning(move |_, _, _| { + let result = result_scval.clone(); + async move { Ok(result) }.boxed() + }); + + let provider = Arc::new(mock); + let service = create_test_service(provider); + + let result = service + .get_token_to_xlm_quote( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + 1_000_000, + 0.5, + None, + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::NoPathFound => {} + e => panic!("Expected NoPathFound error, got {:?}", e), + } + } + + #[tokio::test] + async fn test_get_token_to_xlm_quote_zero_output_returns_no_path() { + let mut mock = MockStellarProviderTrait::new(); + + let amounts: Vec = vec![1_000_000, 0]; + let sc_vals: Vec = amounts + .iter() + .map(|&a| SoroswapService::::i128_to_scval(a)) + .collect(); + let sc_vec: ScVec = sc_vals.try_into().unwrap(); + let result_scval = ScVal::Vec(Some(sc_vec)); + + mock.expect_call_contract().returning(move |_, _, _| { + let result = result_scval.clone(); + async move { Ok(result) }.boxed() + }); + + let provider = Arc::new(mock); + let service = create_test_service(provider); + + let result = service + .get_token_to_xlm_quote( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + 1_000_000, + 0.5, + None, + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::NoPathFound => {} + e => panic!("Expected NoPathFound error, got {:?}", e), + } + } + + #[tokio::test] + async fn test_get_token_to_xlm_quote_negative_output_returns_no_path() { + let mut mock = MockStellarProviderTrait::new(); + + let amounts: Vec = vec![1_000_000, -100]; + let sc_vals: Vec = amounts + .iter() + .map(|&a| SoroswapService::::i128_to_scval(a)) + .collect(); + let sc_vec: ScVec = sc_vals.try_into().unwrap(); + let result_scval = ScVal::Vec(Some(sc_vec)); + + mock.expect_call_contract().returning(move |_, _, _| { + let result = result_scval.clone(); + async move { Ok(result) }.boxed() + }); + + let provider = Arc::new(mock); + let service = create_test_service(provider); + + let result = service + .get_token_to_xlm_quote( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + 1_000_000, + 0.5, + None, + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::NoPathFound => {} + e => panic!("Expected NoPathFound error, got {:?}", e), + } + } + + #[tokio::test] + async fn test_get_token_to_xlm_quote_provider_error() { + let mut mock = MockStellarProviderTrait::new(); + + mock.expect_call_contract().returning(|_, _, _| { + async move { + Err(crate::services::provider::ProviderError::Other( + "Connection failed".to_string(), + )) + } + .boxed() + }); + + let provider = Arc::new(mock); + let service = create_test_service(provider); + + let result = service + .get_token_to_xlm_quote( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + 1_000_000, + 0.5, + None, + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::ApiError { message } => { + assert!(message.contains("router call failed")); + } + e => panic!("Expected ApiError, got {:?}", e), + } + } + + // ==================== prepare_swap_transaction Tests ==================== + + #[tokio::test] + async fn test_prepare_swap_transaction_token_to_native() { + let mut mock = MockStellarProviderTrait::new(); + + let amounts: Vec = vec![1_000_000, 950_000]; + let sc_vals: Vec = amounts + .iter() + .map(|&a| SoroswapService::::i128_to_scval(a)) + .collect(); + let sc_vec: ScVec = sc_vals.try_into().unwrap(); + let result_scval = ScVal::Vec(Some(sc_vec)); + + mock.expect_call_contract().returning(move |_, _, _| { + let result = result_scval.clone(); + async move { Ok(result) }.boxed() + }); + + let provider = Arc::new(mock); + let service = create_test_service(provider); + + let params = SwapTransactionParams { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + source_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + destination_asset: "native".to_string(), + amount: 1_000_000, + slippage_percent: 0.5, + network_passphrase: "Test SDF Network ; September 2015".to_string(), + source_asset_decimals: Some(7), + destination_asset_decimals: None, + }; + + let (xdr, quote) = service.prepare_swap_transaction(params).await.unwrap(); + + assert!(!xdr.is_empty()); + assert_eq!(quote.out_amount, 950_000); + + // Verify XDR is a valid TransactionEnvelope with InvokeHostFunction + let envelope = TransactionEnvelope::from_xdr_base64(&xdr, Limits::none()).unwrap(); + match &envelope { + TransactionEnvelope::Tx(env) => { + assert_eq!(env.tx.operations.len(), 1); + assert!(matches!( + env.tx.operations[0].body, + OperationBody::InvokeHostFunction(_) + )); + // Sequence should be 0 (placeholder) + assert_eq!(env.tx.seq_num.0, 0); + } + _ => panic!("Expected Tx envelope"), + } + } + + #[tokio::test] + async fn test_prepare_swap_transaction_native_to_token() { + let mut mock = MockStellarProviderTrait::new(); + + let amounts: Vec = vec![1_000_000, 1_050_000]; + let sc_vals: Vec = amounts + .iter() + .map(|&a| SoroswapService::::i128_to_scval(a)) + .collect(); + let sc_vec: ScVec = sc_vals.try_into().unwrap(); + let result_scval = ScVal::Vec(Some(sc_vec)); + + mock.expect_call_contract().returning(move |_, _, _| { + let result = result_scval.clone(); + async move { Ok(result) }.boxed() + }); + + let provider = Arc::new(mock); + let service = create_test_service(provider); + + let params = SwapTransactionParams { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + source_asset: "native".to_string(), + destination_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" + .to_string(), + amount: 1_000_000, + slippage_percent: 0.5, + network_passphrase: "Test SDF Network ; September 2015".to_string(), + source_asset_decimals: None, + destination_asset_decimals: Some(7), + }; + + let (xdr, quote) = service.prepare_swap_transaction(params).await.unwrap(); + + assert!(!xdr.is_empty()); + assert_eq!(quote.out_amount, 1_050_000); + + // Verify XDR is valid + let envelope = TransactionEnvelope::from_xdr_base64(&xdr, Limits::none()).unwrap(); + match &envelope { + TransactionEnvelope::Tx(env) => { + assert_eq!(env.tx.operations.len(), 1); + assert!(matches!( + env.tx.operations[0].body, + OperationBody::InvokeHostFunction(_) + )); + } + _ => panic!("Expected Tx envelope"), + } + } + + #[tokio::test] + async fn test_prepare_swap_transaction_token_to_token_not_supported() { + let provider = create_mock_provider(); + let service = create_test_service(provider); + + let params = SwapTransactionParams { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + source_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + destination_asset: "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA" + .to_string(), + amount: 1_000_000, + slippage_percent: 0.5, + network_passphrase: "Test SDF Network ; September 2015".to_string(), + source_asset_decimals: Some(7), + destination_asset_decimals: Some(7), + }; + + let result = service.prepare_swap_transaction(params).await; + + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::InvalidAssetIdentifier(msg) => { + assert!(msg.contains("only supports swaps involving native XLM")); + } + e => panic!("Expected InvalidAssetIdentifier, got {:?}", e), + } + } + + // ==================== parse_account_to_sc_address Tests ==================== + + #[test] + fn test_parse_account_to_sc_address_valid() { + let addr = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + let result = SoroswapService::::parse_account_to_sc_address(addr); + assert!(result.is_ok()); + match result.unwrap() { + ScAddress::Account(_) => {} + _ => panic!("Expected Account address"), + } + } + + #[test] + fn test_parse_account_to_sc_address_invalid() { + let addr = "INVALID"; + let result = SoroswapService::::parse_account_to_sc_address(addr); + assert!(result.is_err()); + } + + // ==================== build_swap_transaction_xdr Tests ==================== + + #[test] + fn test_build_swap_transaction_xdr_token_to_xlm() { + let provider = create_mock_provider(); + let service = create_test_service(provider); + + let quote = StellarQuoteResponse { + input_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + output_asset: "native".to_string(), + in_amount: 1_000_000, + out_amount: 950_000, + price_impact_pct: 0.0, + slippage_bps: 50, + path: None, + }; + + let params = SwapTransactionParams { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + source_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + destination_asset: "native".to_string(), + amount: 1_000_000, + slippage_percent: 0.5, + network_passphrase: "Test SDF Network ; September 2015".to_string(), + source_asset_decimals: Some(7), + destination_asset_decimals: None, + }; + + let xdr = service.build_swap_transaction_xdr(¶ms, "e).unwrap(); + + // Verify the XDR parses correctly + let envelope = TransactionEnvelope::from_xdr_base64(&xdr, Limits::none()).unwrap(); + match &envelope { + TransactionEnvelope::Tx(env) => { + // Verify transaction structure + assert_eq!(env.tx.operations.len(), 1); + assert_eq!(env.tx.seq_num.0, 0); // Placeholder + assert_eq!(env.tx.fee, STELLAR_DEFAULT_TRANSACTION_FEE); + assert!(env.signatures.is_empty()); // Unsigned + + // Verify it's an InvokeHostFunction with InvokeContract + match &env.tx.operations[0].body { + OperationBody::InvokeHostFunction(op) => { + match &op.host_function { + HostFunction::InvokeContract(args) => { + // Verify router contract address + match &args.contract_address { + ScAddress::Contract(_) => {} + _ => panic!("Expected Contract address for router"), + } + // Verify function name + assert_eq!( + args.function_name.to_string(), + "swap_exact_tokens_for_tokens" + ); + // Verify 5 arguments + assert_eq!(args.args.len(), 5); + } + _ => panic!("Expected InvokeContract"), + } + // Auth should be empty (simulation fills it) + assert!(op.auth.is_empty()); + } + _ => panic!("Expected InvokeHostFunction"), + } + + // Verify time bounds + match &env.tx.cond { + Preconditions::Time(tb) => { + assert_eq!(tb.min_time.0, 0); + assert!(tb.max_time.0 > 0); + } + _ => panic!("Expected Time preconditions"), + } + } + _ => panic!("Expected Tx envelope"), + } + } + + #[test] + fn test_build_swap_transaction_xdr_native_to_token() { + let provider = create_mock_provider(); + let service = create_test_service(provider); + + let quote = StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + in_amount: 1_000_000, + out_amount: 1_050_000, + price_impact_pct: 0.0, + slippage_bps: 50, + path: None, + }; + + let params = SwapTransactionParams { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + source_asset: "native".to_string(), + destination_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" + .to_string(), + amount: 1_000_000, + slippage_percent: 0.5, + network_passphrase: "Test SDF Network ; September 2015".to_string(), + source_asset_decimals: None, + destination_asset_decimals: Some(7), + }; + + let xdr = service.build_swap_transaction_xdr(¶ms, "e).unwrap(); + + // Verify valid XDR with InvokeHostFunction + let envelope = TransactionEnvelope::from_xdr_base64(&xdr, Limits::none()).unwrap(); + match &envelope { + TransactionEnvelope::Tx(env) => { + assert_eq!(env.tx.operations.len(), 1); + match &env.tx.operations[0].body { + OperationBody::InvokeHostFunction(op) => match &op.host_function { + HostFunction::InvokeContract(args) => { + assert_eq!( + args.function_name.to_string(), + "swap_exact_tokens_for_tokens" + ); + assert_eq!(args.args.len(), 5); + } + _ => panic!("Expected InvokeContract"), + }, + _ => panic!("Expected InvokeHostFunction"), + } + } + _ => panic!("Expected Tx envelope"), + } + } + + #[test] + fn test_build_swap_transaction_xdr_invalid_source_account() { + let provider = create_mock_provider(); + let service = create_test_service(provider); + + let quote = StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: "native".to_string(), + in_amount: 1_000_000, + out_amount: 1_000_000, + price_impact_pct: 0.0, + slippage_bps: 50, + path: None, + }; + + let params = SwapTransactionParams { + source_account: "INVALID_ACCOUNT".to_string(), + source_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + destination_asset: "native".to_string(), + amount: 1_000_000, + slippage_percent: 0.5, + network_passphrase: "Test SDF Network ; September 2015".to_string(), + source_asset_decimals: None, + destination_asset_decimals: None, + }; + + let result = service.build_swap_transaction_xdr(¶ms, "e); + assert!(result.is_err()); + } + + #[test] + fn test_build_swap_transaction_xdr_slippage_calculation() { + let provider = create_mock_provider(); + let service = create_test_service(provider); + + // With 100 bps (1%) slippage on 1_000_000 output, min should be 990_000 + let quote = StellarQuoteResponse { + input_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + output_asset: "native".to_string(), + in_amount: 1_000_000, + out_amount: 1_000_000, + price_impact_pct: 0.0, + slippage_bps: 100, // 1% + path: None, + }; + + let params = SwapTransactionParams { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + source_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + destination_asset: "native".to_string(), + amount: 1_000_000, + slippage_percent: 1.0, + network_passphrase: "Test SDF Network ; September 2015".to_string(), + source_asset_decimals: Some(7), + destination_asset_decimals: None, + }; + + let xdr = service.build_swap_transaction_xdr(¶ms, "e).unwrap(); + + // Parse and verify amount_out_min argument + let envelope = TransactionEnvelope::from_xdr_base64(&xdr, Limits::none()).unwrap(); + match &envelope { + TransactionEnvelope::Tx(env) => { + match &env.tx.operations[0].body { + OperationBody::InvokeHostFunction(op) => { + match &op.host_function { + HostFunction::InvokeContract(args) => { + // args[1] is amount_out_min + let amount_out_min = + SoroswapService::::scval_to_i128( + &args.args[1], + ) + .unwrap(); + // 1_000_000 * (10000 - 100) / 10000 = 990_000 + assert_eq!(amount_out_min, 990_000); + } + _ => panic!("Expected InvokeContract"), + } + } + _ => panic!("Expected InvokeHostFunction"), + } + } + _ => panic!("Expected Tx envelope"), + } + } + + // ==================== execute_swap Tests ==================== + + #[tokio::test] + async fn test_execute_swap_not_implemented() { + let provider = create_mock_provider(); + let service = create_test_service(provider); + + let params = SwapTransactionParams { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + source_asset: "native".to_string(), + destination_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" + .to_string(), + amount: 1_000_000, + slippage_percent: 0.5, + network_passphrase: "Test SDF Network ; September 2015".to_string(), + source_asset_decimals: None, + destination_asset_decimals: Some(7), + }; + + let result = service.execute_swap(params).await; + + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::UnknownError(msg) => { + assert!(msg.contains("not yet implemented")); + } + e => panic!("Expected UnknownError, got {:?}", e), + } + } + + // ==================== Price Impact Calculation Tests ==================== + + #[tokio::test] + async fn test_price_impact_calculation() { + let mut mock = MockStellarProviderTrait::new(); + + // 10% price impact: in 1_000_000, out 900_000 + let amounts: Vec = vec![1_000_000, 900_000]; + let sc_vals: Vec = amounts + .iter() + .map(|&a| SoroswapService::::i128_to_scval(a)) + .collect(); + let sc_vec: ScVec = sc_vals.try_into().unwrap(); + let result_scval = ScVal::Vec(Some(sc_vec)); + + mock.expect_call_contract().returning(move |_, _, _| { + let result = result_scval.clone(); + async move { Ok(result) }.boxed() + }); + + let provider = Arc::new(mock); + let service = create_test_service(provider); + + let quote = service + .get_token_to_xlm_quote( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + 1_000_000, + 0.5, + None, + ) + .await + .unwrap(); + + // Price impact is not calculated for token -> XLM quotes (returns 0.0) + assert_eq!(quote.price_impact_pct, 0.0); + } +} diff --git a/src/services/stellar_dex/stellar_dex_service.rs b/src/services/stellar_dex/stellar_dex_service.rs index 58680781c..a31194033 100644 --- a/src/services/stellar_dex/stellar_dex_service.rs +++ b/src/services/stellar_dex/stellar_dex_service.rs @@ -5,7 +5,7 @@ //! and internally routes calls to the first strategy that can handle the requested asset. use super::{ - AssetType, OrderBookService, StellarDexServiceError, StellarDexServiceTrait, + AssetType, OrderBookService, SoroswapService, StellarDexServiceError, StellarDexServiceTrait, StellarQuoteResponse, SwapExecutionResult, SwapTransactionParams, }; use crate::services::{provider::StellarProviderTrait, signer::Signer, signer::StellarSignTrait}; @@ -24,10 +24,10 @@ where P: StellarProviderTrait + Send + Sync + 'static, S: StellarSignTrait + Signer + Send + Sync + 'static, { - /// Order Book DEX service + /// Order Book DEX service (for classic Stellar assets) OrderBook(Arc>), - // TODO: Add Soroswap variant when implemented - // Soroswap(Arc>), + /// Soroswap DEX service (for Soroban contract tokens) + Soroswap(Arc>), } impl DexServiceWrapper @@ -38,14 +38,14 @@ where fn can_handle_asset(&self, asset_id: &str) -> bool { match self { DexServiceWrapper::OrderBook(service) => service.can_handle_asset(asset_id), - // DexServiceWrapper::Soroswap(service) => service.can_handle_asset(asset_id), + DexServiceWrapper::Soroswap(service) => service.can_handle_asset(asset_id), } } fn supported_asset_types(&self) -> HashSet { match self { DexServiceWrapper::OrderBook(service) => service.supported_asset_types(), - // DexServiceWrapper::Soroswap(service) => service.supported_asset_types(), + DexServiceWrapper::Soroswap(service) => service.supported_asset_types(), } } } @@ -137,10 +137,11 @@ where DexServiceWrapper::OrderBook(svc) => { svc.get_token_to_xlm_quote(asset_id, amount, slippage, asset_decimals) .await - } // DexServiceWrapper::Soroswap(svc) => { - // svc.get_token_to_xlm_quote(asset_id, amount, slippage, asset_decimals) - // .await - // } + } + DexServiceWrapper::Soroswap(svc) => { + svc.get_token_to_xlm_quote(asset_id, amount, slippage, asset_decimals) + .await + } } } @@ -161,10 +162,11 @@ where DexServiceWrapper::OrderBook(svc) => { svc.get_xlm_to_token_quote(asset_id, amount, slippage, asset_decimals) .await - } // DexServiceWrapper::Soroswap(svc) => { - // svc.get_xlm_to_token_quote(asset_id, amount, slippage, asset_decimals) - // .await - // } + } + DexServiceWrapper::Soroswap(svc) => { + svc.get_xlm_to_token_quote(asset_id, amount, slippage, asset_decimals) + .await + } } } @@ -183,7 +185,7 @@ where match strategy { DexServiceWrapper::OrderBook(svc) => svc.prepare_swap_transaction(params).await, - // DexServiceWrapper::Soroswap(svc) => svc.prepare_swap_transaction(params).await, + DexServiceWrapper::Soroswap(svc) => svc.prepare_swap_transaction(params).await, } } @@ -202,7 +204,499 @@ where match strategy { DexServiceWrapper::OrderBook(svc) => svc.execute_swap(params).await, - // DexServiceWrapper::Soroswap(svc) => svc.execute_swap(params).await, + DexServiceWrapper::Soroswap(svc) => svc.execute_swap(params).await, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::SignerError; + use crate::services::provider::MockStellarProviderTrait; + use crate::services::signer::{MockStellarSignTrait, Signer}; + use async_trait::async_trait; + + // ==================== Mock Setup ==================== + + /// Combined mock that implements both StellarSignTrait and Signer + struct MockCombinedSigner { + stellar_mock: MockStellarSignTrait, + } + + impl MockCombinedSigner { + fn new() -> Self { + Self { + stellar_mock: MockStellarSignTrait::new(), + } + } + } + + #[async_trait] + impl StellarSignTrait for MockCombinedSigner { + async fn sign_xdr_transaction( + &self, + unsigned_xdr: &str, + network_passphrase: &str, + ) -> Result + { + self.stellar_mock + .sign_xdr_transaction(unsigned_xdr, network_passphrase) + .await + } + } + + #[async_trait] + impl Signer for MockCombinedSigner { + async fn address(&self) -> Result { + Ok(crate::models::Address::Stellar( + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + )) + } + + async fn sign_transaction( + &self, + _transaction: crate::models::NetworkTransactionData, + ) -> Result { + Ok(crate::domain::SignTransactionResponse::Stellar( + crate::domain::SignTransactionResponseStellar { + signature: crate::models::DecoratedSignature { + hint: soroban_rs::xdr::SignatureHint([0; 4]), + signature: soroban_rs::xdr::Signature( + soroban_rs::xdr::BytesM::try_from(vec![0u8; 64]).unwrap(), + ), + }, + }, + )) + } + } + + // Test constants + const CLASSIC_ASSET: &str = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + const CONTRACT_ASSET: &str = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + + /// Create a test OrderBookService wrapped in Arc + fn create_order_book_service( + ) -> Arc> { + let provider = Arc::new(MockStellarProviderTrait::new()); + let signer = Arc::new(MockCombinedSigner::new()); + Arc::new( + OrderBookService::new( + "https://horizon-testnet.stellar.org".to_string(), + provider, + signer, + ) + .expect("Failed to create OrderBookService"), + ) + } + + /// Create a test SoroswapService wrapped in Arc + fn create_soroswap_service() -> Arc> { + let provider = Arc::new(MockStellarProviderTrait::new()); + Arc::new(SoroswapService::new( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA".to_string(), + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + provider, + "Test SDF Network ; September 2015".to_string(), + )) + } + + /// Create a StellarDexService with both OrderBook and Soroswap strategies + fn create_multi_strategy_service( + ) -> StellarDexService { + let order_book = DexServiceWrapper::OrderBook(create_order_book_service()); + let soroswap = DexServiceWrapper::Soroswap(create_soroswap_service()); + StellarDexService::new(vec![order_book, soroswap]) + } + + /// Create a StellarDexService with only OrderBook strategy + fn create_order_book_only_service( + ) -> StellarDexService { + let order_book = DexServiceWrapper::OrderBook(create_order_book_service()); + StellarDexService::new(vec![order_book]) + } + + /// Create a StellarDexService with only Soroswap strategy + fn create_soroswap_only_service( + ) -> StellarDexService { + let soroswap = DexServiceWrapper::Soroswap(create_soroswap_service()); + StellarDexService::new(vec![soroswap]) + } + + /// Create a StellarDexService with no strategies + fn create_empty_service() -> StellarDexService { + StellarDexService::new(vec![]) + } + + /// Create SwapTransactionParams for testing + fn create_swap_params(source_asset: &str) -> SwapTransactionParams { + SwapTransactionParams { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + source_asset: source_asset.to_string(), + destination_asset: "native".to_string(), + amount: 1000000, + slippage_percent: 1.0, + network_passphrase: "Test SDF Network ; September 2015".to_string(), + source_asset_decimals: Some(7), + destination_asset_decimals: Some(7), + } + } + + // ==================== DexServiceWrapper Tests ==================== + + #[test] + fn test_wrapper_order_book_can_handle_native() { + let wrapper: DexServiceWrapper = + DexServiceWrapper::OrderBook(create_order_book_service()); + assert!(wrapper.can_handle_asset("native")); + } + + #[test] + fn test_wrapper_order_book_can_handle_classic_asset() { + let wrapper: DexServiceWrapper = + DexServiceWrapper::OrderBook(create_order_book_service()); + assert!(wrapper.can_handle_asset(CLASSIC_ASSET)); + } + + #[test] + fn test_wrapper_order_book_cannot_handle_contract() { + let wrapper: DexServiceWrapper = + DexServiceWrapper::OrderBook(create_order_book_service()); + assert!(!wrapper.can_handle_asset(CONTRACT_ASSET)); + } + + #[test] + fn test_wrapper_soroswap_can_handle_native() { + let wrapper: DexServiceWrapper = + DexServiceWrapper::Soroswap(create_soroswap_service()); + assert!(wrapper.can_handle_asset("native")); + } + + #[test] + fn test_wrapper_soroswap_can_handle_contract() { + let wrapper: DexServiceWrapper = + DexServiceWrapper::Soroswap(create_soroswap_service()); + assert!(wrapper.can_handle_asset(CONTRACT_ASSET)); + } + + #[test] + fn test_wrapper_soroswap_cannot_handle_classic_asset() { + let wrapper: DexServiceWrapper = + DexServiceWrapper::Soroswap(create_soroswap_service()); + assert!(!wrapper.can_handle_asset(CLASSIC_ASSET)); + } + + #[test] + fn test_wrapper_order_book_supported_asset_types() { + let wrapper: DexServiceWrapper = + DexServiceWrapper::OrderBook(create_order_book_service()); + let types = wrapper.supported_asset_types(); + assert!(types.contains(&AssetType::Native)); + assert!(types.contains(&AssetType::Classic)); + assert!(!types.contains(&AssetType::Contract)); + } + + #[test] + fn test_wrapper_soroswap_supported_asset_types() { + let wrapper: DexServiceWrapper = + DexServiceWrapper::Soroswap(create_soroswap_service()); + let types = wrapper.supported_asset_types(); + assert!(types.contains(&AssetType::Native)); + assert!(types.contains(&AssetType::Contract)); + assert!(!types.contains(&AssetType::Classic)); + } + + // ==================== StellarDexService Constructor Tests ==================== + + #[test] + fn test_new_with_multiple_strategies() { + let service = create_multi_strategy_service(); + assert_eq!(service.strategies.len(), 2); + } + + #[test] + fn test_new_with_single_strategy() { + let service = create_order_book_only_service(); + assert_eq!(service.strategies.len(), 1); + } + + #[test] + fn test_new_with_empty_strategies() { + let service = create_empty_service(); + assert_eq!(service.strategies.len(), 0); + } + + // ==================== find_strategy_for_asset Tests ==================== + + #[test] + fn test_find_strategy_for_native_asset() { + let service = create_multi_strategy_service(); + let strategy = service.find_strategy_for_asset("native"); + assert!(strategy.is_some()); + } + + #[test] + fn test_find_strategy_for_classic_asset() { + let service = create_multi_strategy_service(); + let strategy = service.find_strategy_for_asset(CLASSIC_ASSET); + assert!(strategy.is_some()); + // Verify it's the OrderBook strategy + assert!(matches!(strategy.unwrap(), DexServiceWrapper::OrderBook(_))); + } + + #[test] + fn test_find_strategy_for_contract_asset() { + let service = create_multi_strategy_service(); + let strategy = service.find_strategy_for_asset(CONTRACT_ASSET); + assert!(strategy.is_some()); + // Verify it's the Soroswap strategy + assert!(matches!(strategy.unwrap(), DexServiceWrapper::Soroswap(_))); + } + + #[test] + fn test_find_strategy_returns_none_for_unhandled_asset() { + let service = create_order_book_only_service(); + // Contract assets are not handled by OrderBook + let strategy = service.find_strategy_for_asset(CONTRACT_ASSET); + assert!(strategy.is_none()); + } + + #[test] + fn test_find_strategy_returns_none_for_empty_service() { + let service = create_empty_service(); + let strategy = service.find_strategy_for_asset("native"); + assert!(strategy.is_none()); + } + + #[test] + fn test_find_strategy_priority_order() { + // OrderBook comes first, so it should handle native assets + let service = create_multi_strategy_service(); + let strategy = service.find_strategy_for_asset("native"); + assert!(strategy.is_some()); + // First strategy (OrderBook) should be selected for native + assert!(matches!(strategy.unwrap(), DexServiceWrapper::OrderBook(_))); + } + + // ==================== StellarDexServiceTrait::supported_asset_types Tests ==================== + + #[test] + fn test_supported_asset_types_union_of_all_strategies() { + let service = create_multi_strategy_service(); + let types = service.supported_asset_types(); + // Should include Native, Classic (from OrderBook), and Contract (from Soroswap) + assert!(types.contains(&AssetType::Native)); + assert!(types.contains(&AssetType::Classic)); + assert!(types.contains(&AssetType::Contract)); + assert_eq!(types.len(), 3); + } + + #[test] + fn test_supported_asset_types_order_book_only() { + let service = create_order_book_only_service(); + let types = service.supported_asset_types(); + assert!(types.contains(&AssetType::Native)); + assert!(types.contains(&AssetType::Classic)); + assert!(!types.contains(&AssetType::Contract)); + } + + #[test] + fn test_supported_asset_types_soroswap_only() { + let service = create_soroswap_only_service(); + let types = service.supported_asset_types(); + assert!(types.contains(&AssetType::Native)); + assert!(types.contains(&AssetType::Contract)); + assert!(!types.contains(&AssetType::Classic)); + } + + #[test] + fn test_supported_asset_types_empty_service() { + let service = create_empty_service(); + let types = service.supported_asset_types(); + assert!(types.is_empty()); + } + + // ==================== StellarDexServiceTrait::can_handle_asset Tests ==================== + + #[test] + fn test_can_handle_asset_native() { + let service = create_multi_strategy_service(); + assert!(service.can_handle_asset("native")); + } + + #[test] + fn test_can_handle_asset_empty_string() { + let service = create_multi_strategy_service(); + assert!(service.can_handle_asset("")); + } + + #[test] + fn test_can_handle_asset_classic() { + let service = create_multi_strategy_service(); + assert!(service.can_handle_asset(CLASSIC_ASSET)); + } + + #[test] + fn test_can_handle_asset_contract() { + let service = create_multi_strategy_service(); + assert!(service.can_handle_asset(CONTRACT_ASSET)); + } + + #[test] + fn test_cannot_handle_contract_with_order_book_only() { + let service = create_order_book_only_service(); + assert!(!service.can_handle_asset(CONTRACT_ASSET)); + } + + #[test] + fn test_cannot_handle_classic_with_soroswap_only() { + let service = create_soroswap_only_service(); + assert!(!service.can_handle_asset(CLASSIC_ASSET)); + } + + #[test] + fn test_cannot_handle_any_asset_with_empty_service() { + let service = create_empty_service(); + assert!(!service.can_handle_asset("native")); + assert!(!service.can_handle_asset(CLASSIC_ASSET)); + assert!(!service.can_handle_asset(CONTRACT_ASSET)); + } + + #[test] + fn test_cannot_handle_invalid_asset() { + let service = create_multi_strategy_service(); + assert!(!service.can_handle_asset("INVALID")); + assert!(!service.can_handle_asset("random_string")); + } + + // ==================== get_token_to_xlm_quote Error Tests ==================== + + #[tokio::test] + async fn test_get_token_to_xlm_quote_no_strategy_error() { + let service = create_empty_service(); + let result = service + .get_token_to_xlm_quote("native", 1000000, 1.0, Some(7)) + .await; + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::InvalidAssetIdentifier(msg) => { + assert!(msg.contains("No configured strategy can handle asset")); + } + _ => panic!("Expected InvalidAssetIdentifier error"), + } + } + + #[tokio::test] + async fn test_get_token_to_xlm_quote_unhandled_asset_error() { + let service = create_order_book_only_service(); + // Contract assets are not handled by OrderBook + let result = service + .get_token_to_xlm_quote(CONTRACT_ASSET, 1000000, 1.0, Some(7)) + .await; + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::InvalidAssetIdentifier(msg) => { + assert!(msg.contains("No configured strategy can handle asset")); + assert!(msg.contains(CONTRACT_ASSET)); + } + _ => panic!("Expected InvalidAssetIdentifier error"), + } + } + + // ==================== get_xlm_to_token_quote Error Tests ==================== + + #[tokio::test] + async fn test_get_xlm_to_token_quote_no_strategy_error() { + let service = create_empty_service(); + let result = service + .get_xlm_to_token_quote("native", 1000000, 1.0, Some(7)) + .await; + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::InvalidAssetIdentifier(msg) => { + assert!(msg.contains("No configured strategy can handle asset")); + } + _ => panic!("Expected InvalidAssetIdentifier error"), + } + } + + #[tokio::test] + async fn test_get_xlm_to_token_quote_unhandled_asset_error() { + let service = create_soroswap_only_service(); + // Classic assets are not handled by Soroswap + let result = service + .get_xlm_to_token_quote(CLASSIC_ASSET, 1000000, 1.0, Some(7)) + .await; + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::InvalidAssetIdentifier(msg) => { + assert!(msg.contains("No configured strategy can handle asset")); + assert!(msg.contains(CLASSIC_ASSET)); + } + _ => panic!("Expected InvalidAssetIdentifier error"), + } + } + + // ==================== prepare_swap_transaction Error Tests ==================== + + #[tokio::test] + async fn test_prepare_swap_transaction_no_strategy_error() { + let service = create_empty_service(); + let params = create_swap_params("native"); + let result = service.prepare_swap_transaction(params).await; + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::InvalidAssetIdentifier(msg) => { + assert!(msg.contains("No configured strategy can handle asset")); + } + _ => panic!("Expected InvalidAssetIdentifier error"), + } + } + + #[tokio::test] + async fn test_prepare_swap_transaction_unhandled_asset_error() { + let service = create_order_book_only_service(); + let params = create_swap_params(CONTRACT_ASSET); + let result = service.prepare_swap_transaction(params).await; + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::InvalidAssetIdentifier(msg) => { + assert!(msg.contains("No configured strategy can handle asset")); + assert!(msg.contains(CONTRACT_ASSET)); + } + _ => panic!("Expected InvalidAssetIdentifier error"), + } + } + + // ==================== execute_swap Error Tests ==================== + + #[tokio::test] + async fn test_execute_swap_no_strategy_error() { + let service = create_empty_service(); + let params = create_swap_params("native"); + let result = service.execute_swap(params).await; + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::InvalidAssetIdentifier(msg) => { + assert!(msg.contains("No configured strategy can handle asset")); + } + _ => panic!("Expected InvalidAssetIdentifier error"), + } + } + + #[tokio::test] + async fn test_execute_swap_unhandled_asset_error() { + let service = create_soroswap_only_service(); + let params = create_swap_params(CLASSIC_ASSET); + let result = service.execute_swap(params).await; + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::InvalidAssetIdentifier(msg) => { + assert!(msg.contains("No configured strategy can handle asset")); + assert!(msg.contains(CLASSIC_ASSET)); + } + _ => panic!("Expected InvalidAssetIdentifier error"), } } } diff --git a/src/services/stellar_fee_forwarder/mod.rs b/src/services/stellar_fee_forwarder/mod.rs new file mode 100644 index 000000000..f4c35c9c6 --- /dev/null +++ b/src/services/stellar_fee_forwarder/mod.rs @@ -0,0 +1,1181 @@ +//! FeeForwarder Service for Soroban Gas Abstraction +//! +//! This module provides functionality to build and manage transactions +//! using the FeeForwarder contract for gas abstraction on Soroban. +//! +//! The FeeForwarder contract enables fee abstraction by allowing users to pay +//! relayers in tokens instead of native XLM. It atomically: +//! 1. Collects fee payment from user +//! 2. Forwards the call to the target contract +//! +//! ## Authorization Flow +//! +//! User signs authorization for `fee_forwarder.forward()` with sub-invocations: +//! - `fee_token.approve(user, fee_forwarder, max_fee_amount, expiration_ledger)` +//! - `target_contract.target_fn(target_args)` (if target requires auth) + +use crate::constants::STELLAR_LEDGER_TIME_SECONDS; +use crate::services::provider::StellarProviderTrait; +use soroban_rs::xdr::{ + ContractId, Hash, Int128Parts, InvokeContractArgs, Limits, Operation, OperationBody, ScAddress, + ScSymbol, ScVal, ScVec, SorobanAddressCredentials, SorobanAuthorizationEntry, + SorobanAuthorizedFunction, SorobanAuthorizedInvocation, SorobanCredentials, VecM, WriteXdr, +}; +use std::sync::Arc; +use thiserror::Error; + +/// Default validity duration for gas abstraction authorizations (2 minutes). +/// +/// Matches sponsored transaction validity so authorization expiration aligns with the submission window. +pub const DEFAULT_VALIDITY_SECONDS: u64 = 120; + +/// Errors that can occur in FeeForwarder operations +#[derive(Error, Debug)] +pub enum FeeForwarderError { + #[error("Invalid contract address: {0}")] + InvalidContractAddress(String), + + #[error("Invalid account address: {0}")] + InvalidAccountAddress(String), + + #[error("Failed to build authorization: {0}")] + AuthorizationBuildError(String), + + #[error("Provider error: {0}")] + ProviderError(String), + + #[error("XDR serialization error: {0}")] + XdrError(String), + + #[error("Invalid function name: {0}")] + InvalidFunctionName(String), +} + +/// Parameters for building a FeeForwarder transaction +#[derive(Debug, Clone)] +pub struct FeeForwarderParams { + /// Soroban token contract address for fee payment + pub fee_token: String, + /// Actual fee amount to charge (determined at submission time) + pub fee_amount: i128, + /// Maximum fee amount user authorized + pub max_fee_amount: i128, + /// Authorization expiration ledger + pub expiration_ledger: u32, + /// Target contract address to call + pub target_contract: String, + /// Target function name + pub target_fn: String, + /// Target function arguments + pub target_args: Vec, + /// User's Stellar address + pub user: String, + /// Relayer's Stellar address (fee recipient) + pub relayer: String, +} + +/// Service for building FeeForwarder transactions +pub struct FeeForwarderService

+where + P: StellarProviderTrait + Send + Sync, +{ + /// FeeForwarder contract address + fee_forwarder_address: String, + /// Stellar provider for network queries + provider: Arc

, +} + +impl

FeeForwarderService

+where + P: StellarProviderTrait + Send + Sync, +{ + /// Create a new FeeForwarderService + /// + /// # Arguments + /// + /// * `fee_forwarder_address` - The deployed FeeForwarder contract address + /// * `provider` - Stellar provider for network queries + pub fn new(fee_forwarder_address: String, provider: Arc

) -> Self { + Self { + fee_forwarder_address, + provider, + } + } + + /// Build a user authorization entry without requiring a FeeForwarderService instance + /// + /// This static method is useful when you don't have an `Arc

` provider + /// but still need to build authorization entries for the FeeForwarder. + pub fn build_user_auth_entry_standalone( + fee_forwarder_address: &str, + params: &FeeForwarderParams, + requires_target_auth: bool, + ) -> Result { + let fee_forwarder_addr = Self::parse_contract_address(fee_forwarder_address)?; + let fee_token_addr = Self::parse_contract_address(¶ms.fee_token)?; + let target_contract_addr = Self::parse_contract_address(¶ms.target_contract)?; + let user_addr = Self::parse_account_address(¶ms.user)?; + let _relayer_addr = Self::parse_account_address(¶ms.relayer)?; + + // Build sub-invocations + let mut sub_invocations = Vec::new(); + + // 1. fee_token.approve(user, fee_forwarder, max_fee_amount, expiration_ledger) + // Note: When called from another contract, approve requires 'from' address as first arg + let approve_args: ScVec = vec![ + ScVal::Address(user_addr.clone()), + ScVal::Address(fee_forwarder_addr.clone()), + Self::i128_to_scval(params.max_fee_amount), + ScVal::U32(params.expiration_ledger), + ] + .try_into() + .map_err(|_| { + FeeForwarderError::AuthorizationBuildError("Failed to create approve args".to_string()) + })?; + + sub_invocations.push(SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: fee_token_addr, + function_name: Self::create_symbol("approve")?, + args: approve_args.into(), + }), + sub_invocations: VecM::default(), + }); + + // 2. target_contract.target_fn(target_args) - if needed + if requires_target_auth { + let target_args: ScVec = params.target_args.clone().try_into().map_err(|_| { + FeeForwarderError::AuthorizationBuildError( + "Failed to create target args".to_string(), + ) + })?; + + sub_invocations.push(SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: target_contract_addr, + function_name: Self::create_symbol(¶ms.target_fn)?, + args: target_args.into(), + }), + sub_invocations: VecM::default(), + }); + } + + // Build the forward() function arguments for USER (6 parameters) + // User signs: fee_token, max_fee_amount, expiration_ledger, target_contract, target_fn, target_args + // User does NOT sign: fee_amount, user, relayer + let user_auth_args = Self::build_user_auth_args_standalone(params)?; + + // Build the root invocation for fee_forwarder.forward() + let root_invocation = SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: fee_forwarder_addr, + function_name: Self::create_symbol("forward")?, + args: user_auth_args.into(), + }), + sub_invocations: sub_invocations.try_into().map_err(|_| { + FeeForwarderError::AuthorizationBuildError( + "Failed to create sub-invocations".to_string(), + ) + })?, + }; + + // Generate nonce using timestamp combined with randomness for uniqueness + let nonce = { + use rand::Rng; + use std::time::{SystemTime, UNIX_EPOCH}; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as i64) + .unwrap_or(0); + let random: i64 = rand::rng().random(); + timestamp ^ random + }; + + // For simulation, signature must be an empty vector (not Void) + // Void causes Error(Value, UnexpectedType) during auth verification + let empty_signature = ScVal::Vec(Some(ScVec::default())); + + Ok(SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: user_addr, + nonce, + signature_expiration_ledger: params.expiration_ledger, + signature: empty_signature, + }), + root_invocation, + }) + } + + /// Build a relayer authorization entry without requiring a FeeForwarderService instance + /// + /// The FeeForwarder contract requires the relayer to authorize receiving the fee payment. + /// This creates an auth entry for the relayer's authorization of the `forward` call. + /// + /// # Arguments + /// + /// * `fee_forwarder_address` - The FeeForwarder contract address + /// * `params` - FeeForwarder transaction parameters + /// + /// # Returns + /// + /// A SorobanAuthorizationEntry for the relayer (with empty signature for simulation) + pub fn build_relayer_auth_entry_standalone( + fee_forwarder_address: &str, + params: &FeeForwarderParams, + ) -> Result { + let fee_forwarder_addr = Self::parse_contract_address(fee_forwarder_address)?; + let relayer_addr = Self::parse_account_address(¶ms.relayer)?; + + // Build the forward() function arguments + let forward_args = Self::build_forward_args_standalone(fee_forwarder_address, params)?; + + // Build the root invocation for fee_forwarder.forward() + // Relayer only needs to authorize the forward call itself (no sub-invocations) + let root_invocation = SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: fee_forwarder_addr, + function_name: Self::create_symbol("forward")?, + args: forward_args.into(), + }), + sub_invocations: VecM::default(), + }; + + // Generate nonce using timestamp combined with randomness for uniqueness + let nonce = { + use rand::Rng; + use std::time::{SystemTime, UNIX_EPOCH}; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as i64) + .unwrap_or(0); + let random: i64 = rand::rng().random(); + timestamp ^ random + }; + + // For simulation, signature must be an empty vector (not Void) + // Void causes Error(Value, UnexpectedType) during auth verification + let empty_signature = ScVal::Vec(Some(ScVec::default())); + + Ok(SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: relayer_addr, + nonce, + signature_expiration_ledger: params.expiration_ledger, + signature: empty_signature, + }), + root_invocation, + }) + } + + /// Build forward args for USER authorization (6 parameters) + /// + /// User signs: fee_token, max_fee_amount, expiration_ledger, target_contract, target_fn, target_args + /// User does NOT sign: fee_amount, user, relayer + fn build_user_auth_args_standalone( + params: &FeeForwarderParams, + ) -> Result { + let fee_token_addr = Self::parse_contract_address(¶ms.fee_token)?; + let target_contract_addr = Self::parse_contract_address(¶ms.target_contract)?; + + let target_args_vec: ScVec = params.target_args.clone().try_into().map_err(|_| { + FeeForwarderError::AuthorizationBuildError("Failed to create target args".to_string()) + })?; + + // User signs 6 parameters (excludes fee_amount, user, relayer) + let args: Vec = vec![ + ScVal::Address(fee_token_addr), + Self::i128_to_scval(params.max_fee_amount), + ScVal::U32(params.expiration_ledger), + ScVal::Address(target_contract_addr), + ScVal::Symbol(Self::create_symbol(¶ms.target_fn)?), + ScVal::Vec(Some(target_args_vec)), + ]; + + args.try_into().map_err(|_| { + FeeForwarderError::AuthorizationBuildError( + "Failed to create user auth args".to_string(), + ) + }) + } + + /// Build forward args for RELAYER authorization and invoke operation (9 parameters) + /// + /// Relayer signs ALL parameters in the exact order they appear in the function signature: + /// fee_token, fee_amount, max_fee_amount, expiration_ledger, target_contract, target_fn, target_args, user, relayer + fn build_forward_args_standalone( + _fee_forwarder_address: &str, + params: &FeeForwarderParams, + ) -> Result { + let fee_token_addr = Self::parse_contract_address(¶ms.fee_token)?; + let target_contract_addr = Self::parse_contract_address(¶ms.target_contract)?; + let user_addr = Self::parse_account_address(¶ms.user)?; + let relayer_addr = Self::parse_account_address(¶ms.relayer)?; + + let target_args_vec: ScVec = params.target_args.clone().try_into().map_err(|_| { + FeeForwarderError::AuthorizationBuildError("Failed to create target args".to_string()) + })?; + + // Relayer signs all 9 parameters + let args: Vec = vec![ + ScVal::Address(fee_token_addr), + Self::i128_to_scval(params.fee_amount), + Self::i128_to_scval(params.max_fee_amount), + ScVal::U32(params.expiration_ledger), + ScVal::Address(target_contract_addr), + ScVal::Symbol(Self::create_symbol(¶ms.target_fn)?), + ScVal::Vec(Some(target_args_vec)), + ScVal::Address(user_addr), + ScVal::Address(relayer_addr), + ]; + + args.try_into().map_err(|_| { + FeeForwarderError::AuthorizationBuildError("Failed to create forward args".to_string()) + }) + } + + /// Get the FeeForwarder contract address + pub fn fee_forwarder_address(&self) -> &str { + &self.fee_forwarder_address + } + + /// Public wrapper for building forward args (for serializing InvokeContractArgs) + pub fn build_forward_args_for_invoke_contract_args( + fee_forwarder_address: &str, + params: &FeeForwarderParams, + ) -> Result { + Self::build_forward_args_standalone(fee_forwarder_address, params) + } + + /// Public wrapper for parsing a contract address + pub fn parse_contract_address_public(address: &str) -> Result { + Self::parse_contract_address(address) + } + + /// Calculate the expiration ledger for authorization + /// + /// # Arguments + /// + /// * `validity_seconds` - How long the authorization should be valid + /// + /// # Returns + /// + /// The ledger number when the authorization expires + pub async fn get_expiration_ledger( + &self, + validity_seconds: u64, + ) -> Result { + let current_ledger = self + .provider + .get_latest_ledger() + .await + .map_err(|e| FeeForwarderError::ProviderError(e.to_string()))?; + + let ledgers_to_add = validity_seconds / STELLAR_LEDGER_TIME_SECONDS; + Ok(current_ledger.sequence + ledgers_to_add as u32) + } + + /// Parse a Soroban contract address (C...) to ScAddress + fn parse_contract_address(address: &str) -> Result { + let contract = stellar_strkey::Contract::from_string(address).map_err(|e| { + FeeForwarderError::InvalidContractAddress(format!("Invalid contract '{address}': {e}")) + })?; + + Ok(ScAddress::Contract(ContractId(Hash(contract.0)))) + } + + /// Parse a Stellar account address (G...) to ScAddress + fn parse_account_address(address: &str) -> Result { + let account = stellar_strkey::ed25519::PublicKey::from_string(address).map_err(|e| { + FeeForwarderError::InvalidAccountAddress(format!("Invalid account '{address}': {e}")) + })?; + + Ok(ScAddress::Account(soroban_rs::xdr::AccountId( + soroban_rs::xdr::PublicKey::PublicKeyTypeEd25519(soroban_rs::xdr::Uint256(account.0)), + ))) + } + + /// Convert i128 to ScVal::I128 + fn i128_to_scval(amount: i128) -> ScVal { + let hi = (amount >> 64) as i64; + let lo = amount as u64; + ScVal::I128(Int128Parts { hi, lo }) + } + + /// Create a ScSymbol from a function name + fn create_symbol(name: &str) -> Result { + ScSymbol::try_from(name.as_bytes().to_vec()) + .map_err(|_| FeeForwarderError::InvalidFunctionName(name.to_string())) + } + + /// Build the user authorization entry for FeeForwarder.forward() + /// + /// This creates the authorization structure that the user needs to sign. + /// The authorization includes sub-invocations for: + /// - fee_token.approve(user, fee_forwarder, max_fee_amount, expiration_ledger) + /// - target_contract.target_fn(target_args) (if requires_target_auth is true) + /// + /// # Arguments + /// + /// * `params` - FeeForwarder transaction parameters + /// * `requires_target_auth` - Whether the target contract call requires user authorization + /// + /// # Returns + /// + /// A SorobanAuthorizationEntry that the user needs to sign + pub fn build_user_auth_entry( + &self, + params: &FeeForwarderParams, + requires_target_auth: bool, + ) -> Result { + let fee_forwarder_addr = Self::parse_contract_address(&self.fee_forwarder_address)?; + let fee_token_addr = Self::parse_contract_address(¶ms.fee_token)?; + let target_contract_addr = Self::parse_contract_address(¶ms.target_contract)?; + let user_addr = Self::parse_account_address(¶ms.user)?; + // Validate relayer address is valid (used later in build_forward_args) + let _relayer_addr = Self::parse_account_address(¶ms.relayer)?; + + // Build sub-invocations + let mut sub_invocations = Vec::new(); + + // 1. fee_token.approve(user, fee_forwarder, max_fee_amount, expiration_ledger) + // Note: When called from another contract, approve requires 'from' address as first arg + let approve_args: ScVec = vec![ + ScVal::Address(user_addr.clone()), + ScVal::Address(fee_forwarder_addr.clone()), + Self::i128_to_scval(params.max_fee_amount), + ScVal::U32(params.expiration_ledger), + ] + .try_into() + .map_err(|_| { + FeeForwarderError::AuthorizationBuildError("Failed to create approve args".to_string()) + })?; + + sub_invocations.push(SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: fee_token_addr, + function_name: Self::create_symbol("approve")?, + args: approve_args.into(), + }), + sub_invocations: VecM::default(), + }); + + // 2. target_contract.target_fn(target_args) - if needed + if requires_target_auth { + let target_args: ScVec = params.target_args.clone().try_into().map_err(|_| { + FeeForwarderError::AuthorizationBuildError( + "Failed to create target args".to_string(), + ) + })?; + + sub_invocations.push(SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: target_contract_addr.clone(), + function_name: Self::create_symbol(¶ms.target_fn)?, + args: target_args.into(), + }), + sub_invocations: VecM::default(), + }); + } + + // Build the forward() function arguments for USER (6 parameters) + // User signs: fee_token, max_fee_amount, expiration_ledger, target_contract, target_fn, target_args + // User does NOT sign: fee_amount, user, relayer + let user_auth_args = Self::build_user_auth_args_standalone(params)?; + + // Build the root invocation for fee_forwarder.forward() + let root_invocation = SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: fee_forwarder_addr, + function_name: Self::create_symbol("forward")?, + args: user_auth_args.into(), + }), + sub_invocations: sub_invocations.try_into().map_err(|_| { + FeeForwarderError::AuthorizationBuildError( + "Failed to create sub-invocations".to_string(), + ) + })?, + }; + + // Build the authorization entry + // For simulation, signature must be an empty vector (not Void) + // Void causes Error(Value, UnexpectedType) during auth verification + // User will replace this with their actual signature after signing + let empty_signature = ScVal::Vec(Some(ScVec::default())); + + Ok(SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: user_addr, + nonce: self.generate_nonce(), + signature_expiration_ledger: params.expiration_ledger, + signature: empty_signature, + }), + root_invocation, + }) + } + + /// Generate a nonce for authorization + /// + /// The nonce should be unique per authorization to prevent replay attacks. + /// Combines timestamp with randomness for improved uniqueness. + fn generate_nonce(&self) -> i64 { + use rand::Rng; + use std::time::{SystemTime, UNIX_EPOCH}; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as i64) + .unwrap_or(0); + let random: i64 = rand::rng().random(); + timestamp ^ random + } + + /// Serialize an authorization entry to base64 XDR + pub fn serialize_auth_entry( + auth: &SorobanAuthorizationEntry, + ) -> Result { + auth.to_xdr_base64(Limits::none()) + .map_err(|e| FeeForwarderError::XdrError(format!("Failed to serialize auth: {e}"))) + } + + /// Deserialize an authorization entry from base64 XDR + pub fn deserialize_auth_entry( + xdr: &str, + ) -> Result { + use soroban_rs::xdr::ReadXdr; + SorobanAuthorizationEntry::from_xdr_base64(xdr, Limits::none()) + .map_err(|e| FeeForwarderError::XdrError(format!("Failed to deserialize auth: {e}"))) + } + + /// Build the InvokeHostFunction operation for FeeForwarder.forward() + /// + /// This creates the operation that will be included in the transaction. + /// The authorization entries should include both user and relayer signatures. + /// + /// # Arguments + /// + /// * `params` - FeeForwarder transaction parameters + /// * `auth_entries` - Signed authorization entries (user + relayer) + pub fn build_invoke_operation( + &self, + params: &FeeForwarderParams, + auth_entries: Vec, + ) -> Result { + Self::build_invoke_operation_standalone(&self.fee_forwarder_address, params, auth_entries) + } + + /// Build the InvokeHostFunction operation without requiring a service instance. + /// + /// This static method is useful when you don't have an `Arc

` provider + /// but still need to build InvokeHostFunction operations for the FeeForwarder. + pub fn build_invoke_operation_standalone( + fee_forwarder_address: &str, + params: &FeeForwarderParams, + auth_entries: Vec, + ) -> Result { + let fee_forwarder_addr = Self::parse_contract_address(fee_forwarder_address)?; + let forward_args = Self::build_forward_args_standalone(fee_forwarder_address, params)?; + + let host_function = soroban_rs::xdr::HostFunction::InvokeContract(InvokeContractArgs { + contract_address: fee_forwarder_addr, + function_name: Self::create_symbol("forward")?, + args: forward_args.into(), + }); + + let invoke_op = soroban_rs::xdr::InvokeHostFunctionOp { + host_function, + auth: auth_entries.try_into().map_err(|_| { + FeeForwarderError::AuthorizationBuildError( + "Failed to create auth entries vector".to_string(), + ) + })?, + }; + + Ok(Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(invoke_op), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::provider::StellarProvider; + + // Test constants + const VALID_CONTRACT_ADDR: &str = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + const VALID_ACCOUNT_ADDR: &str = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + const VALID_ACCOUNT_ADDR_2: &str = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + + fn create_test_params() -> FeeForwarderParams { + FeeForwarderParams { + fee_token: VALID_CONTRACT_ADDR.to_string(), + fee_amount: 1_000_000, + max_fee_amount: 2_000_000, + expiration_ledger: 100000, + target_contract: VALID_CONTRACT_ADDR.to_string(), + target_fn: "transfer".to_string(), + target_args: vec![ScVal::U32(42)], + user: VALID_ACCOUNT_ADDR.to_string(), + relayer: VALID_ACCOUNT_ADDR_2.to_string(), + } + } + + // ==================== parse_contract_address tests ==================== + + #[test] + fn test_parse_contract_address_valid() { + let result = + FeeForwarderService::::parse_contract_address(VALID_CONTRACT_ADDR); + assert!(result.is_ok()); + match result.unwrap() { + ScAddress::Contract(_) => {} + _ => panic!("Expected Contract address"), + } + } + + #[test] + fn test_parse_contract_address_invalid() { + let addr = "INVALID"; + let result = FeeForwarderService::::parse_contract_address(addr); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, FeeForwarderError::InvalidContractAddress(_))); + } + + #[test] + fn test_parse_contract_address_with_account_address() { + // Account addresses (G...) should fail when parsed as contract addresses + let result = + FeeForwarderService::::parse_contract_address(VALID_ACCOUNT_ADDR); + assert!(result.is_err()); + } + + #[test] + fn test_parse_contract_address_empty() { + let result = FeeForwarderService::::parse_contract_address(""); + assert!(result.is_err()); + } + + #[test] + fn test_parse_contract_address_public() { + let result = FeeForwarderService::::parse_contract_address_public( + VALID_CONTRACT_ADDR, + ); + assert!(result.is_ok()); + } + + // ==================== parse_account_address tests ==================== + + #[test] + fn test_parse_account_address_valid() { + let result = + FeeForwarderService::::parse_account_address(VALID_ACCOUNT_ADDR); + assert!(result.is_ok()); + match result.unwrap() { + ScAddress::Account(_) => {} + _ => panic!("Expected Account address"), + } + } + + #[test] + fn test_parse_account_address_valid_2() { + let result = + FeeForwarderService::::parse_account_address(VALID_ACCOUNT_ADDR_2); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_account_address_invalid_format() { + let addr = "GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGH"; + let result = FeeForwarderService::::parse_account_address(addr); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, FeeForwarderError::InvalidAccountAddress(_))); + } + + #[test] + fn test_parse_account_address_with_contract_address() { + // Contract addresses (C...) should fail when parsed as account addresses + let result = + FeeForwarderService::::parse_account_address(VALID_CONTRACT_ADDR); + assert!(result.is_err()); + } + + #[test] + fn test_parse_account_address_empty() { + let result = FeeForwarderService::::parse_account_address(""); + assert!(result.is_err()); + } + + // ==================== i128_to_scval tests ==================== + + #[test] + fn test_i128_to_scval() { + let amount: i128 = 1_000_000_000; + let scval = FeeForwarderService::::i128_to_scval(amount); + match scval { + ScVal::I128(parts) => { + let recovered = ((parts.hi as i128) << 64) | (parts.lo as i128); + assert_eq!(amount, recovered); + } + _ => panic!("Expected I128"), + } + } + + #[test] + fn test_i128_to_scval_zero() { + let amount: i128 = 0; + let scval = FeeForwarderService::::i128_to_scval(amount); + match scval { + ScVal::I128(parts) => { + assert_eq!(parts.hi, 0); + assert_eq!(parts.lo, 0); + } + _ => panic!("Expected I128"), + } + } + + #[test] + fn test_i128_to_scval_negative() { + let amount: i128 = -1_000_000; + let scval = FeeForwarderService::::i128_to_scval(amount); + match scval { + ScVal::I128(parts) => { + let recovered = ((parts.hi as i128) << 64) | (parts.lo as i128); + assert_eq!(amount, recovered); + } + _ => panic!("Expected I128"), + } + } + + #[test] + fn test_i128_to_scval_max() { + let amount: i128 = i128::MAX; + let scval = FeeForwarderService::::i128_to_scval(amount); + match scval { + ScVal::I128(parts) => { + let recovered = ((parts.hi as i128) << 64) | (parts.lo as i128); + assert_eq!(amount, recovered); + } + _ => panic!("Expected I128"), + } + } + + #[test] + fn test_i128_to_scval_min() { + let amount: i128 = i128::MIN; + let scval = FeeForwarderService::::i128_to_scval(amount); + match scval { + ScVal::I128(parts) => { + let recovered = ((parts.hi as i128) << 64) | (parts.lo as i128); + assert_eq!(amount, recovered); + } + _ => panic!("Expected I128"), + } + } + + // ==================== create_symbol tests ==================== + + #[test] + fn test_create_symbol() { + let result = FeeForwarderService::::create_symbol("forward"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().to_utf8_string_lossy(), "forward"); + } + + #[test] + fn test_create_symbol_approve() { + let result = FeeForwarderService::::create_symbol("approve"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().to_utf8_string_lossy(), "approve"); + } + + #[test] + fn test_create_symbol_transfer() { + let result = FeeForwarderService::::create_symbol("transfer"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().to_utf8_string_lossy(), "transfer"); + } + + #[test] + fn test_create_symbol_empty() { + let result = FeeForwarderService::::create_symbol(""); + assert!(result.is_ok()); + } + + // ==================== build_user_auth_entry_standalone tests ==================== + + #[test] + fn test_build_user_auth_entry_standalone_without_target_auth() { + let params = create_test_params(); + let result = FeeForwarderService::::build_user_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + false, + ); + assert!(result.is_ok()); + + let auth_entry = result.unwrap(); + match &auth_entry.credentials { + SorobanCredentials::Address(creds) => { + assert_eq!(creds.signature_expiration_ledger, params.expiration_ledger); + // Signature should be empty vec for simulation + match &creds.signature { + ScVal::Vec(Some(v)) => assert!(v.is_empty()), + _ => panic!("Expected empty Vec signature"), + } + } + _ => panic!("Expected Address credentials"), + } + + // Should have 1 sub-invocation (approve only) + assert_eq!(auth_entry.root_invocation.sub_invocations.len(), 1); + } + + #[test] + fn test_build_user_auth_entry_standalone_with_target_auth() { + let params = create_test_params(); + let result = FeeForwarderService::::build_user_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + true, + ); + assert!(result.is_ok()); + + let auth_entry = result.unwrap(); + // Should have 2 sub-invocations (approve + target) + assert_eq!(auth_entry.root_invocation.sub_invocations.len(), 2); + } + + #[test] + fn test_build_user_auth_entry_standalone_invalid_fee_forwarder() { + let params = create_test_params(); + let result = FeeForwarderService::::build_user_auth_entry_standalone( + "INVALID", ¶ms, false, + ); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + FeeForwarderError::InvalidContractAddress(_) + )); + } + + #[test] + fn test_build_user_auth_entry_standalone_invalid_fee_token() { + let mut params = create_test_params(); + params.fee_token = "INVALID".to_string(); + let result = FeeForwarderService::::build_user_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + false, + ); + assert!(result.is_err()); + } + + #[test] + fn test_build_user_auth_entry_standalone_invalid_user() { + let mut params = create_test_params(); + params.user = "INVALID".to_string(); + let result = FeeForwarderService::::build_user_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + false, + ); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + FeeForwarderError::InvalidAccountAddress(_) + )); + } + + #[test] + fn test_build_user_auth_entry_standalone_invalid_relayer() { + let mut params = create_test_params(); + params.relayer = "INVALID".to_string(); + let result = FeeForwarderService::::build_user_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + false, + ); + assert!(result.is_err()); + } + + // ==================== build_relayer_auth_entry_standalone tests ==================== + + #[test] + fn test_build_relayer_auth_entry_standalone() { + let params = create_test_params(); + let result = FeeForwarderService::::build_relayer_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + ); + assert!(result.is_ok()); + + let auth_entry = result.unwrap(); + match &auth_entry.credentials { + SorobanCredentials::Address(creds) => { + assert_eq!(creds.signature_expiration_ledger, params.expiration_ledger); + // Relayer has no sub-invocations + } + _ => panic!("Expected Address credentials"), + } + + // Relayer should have no sub-invocations + assert!(auth_entry.root_invocation.sub_invocations.is_empty()); + } + + #[test] + fn test_build_relayer_auth_entry_standalone_invalid_fee_forwarder() { + let params = create_test_params(); + let result = FeeForwarderService::::build_relayer_auth_entry_standalone( + "INVALID", ¶ms, + ); + assert!(result.is_err()); + } + + #[test] + fn test_build_relayer_auth_entry_standalone_invalid_relayer() { + let mut params = create_test_params(); + params.relayer = "INVALID".to_string(); + let result = FeeForwarderService::::build_relayer_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + ); + assert!(result.is_err()); + } + + // ==================== build_forward_args tests ==================== + + #[test] + fn test_build_forward_args_for_invoke_contract_args() { + let params = create_test_params(); + let result = + FeeForwarderService::::build_forward_args_for_invoke_contract_args( + VALID_CONTRACT_ADDR, + ¶ms, + ); + assert!(result.is_ok()); + + let args = result.unwrap(); + // Should have 9 arguments + assert_eq!(args.len(), 9); + } + + #[test] + fn test_build_forward_args_standalone_invalid_fee_token() { + let mut params = create_test_params(); + params.fee_token = "INVALID".to_string(); + let result = + FeeForwarderService::::build_forward_args_for_invoke_contract_args( + VALID_CONTRACT_ADDR, + ¶ms, + ); + assert!(result.is_err()); + } + + #[test] + fn test_build_forward_args_standalone_invalid_target_contract() { + let mut params = create_test_params(); + params.target_contract = "INVALID".to_string(); + let result = + FeeForwarderService::::build_forward_args_for_invoke_contract_args( + VALID_CONTRACT_ADDR, + ¶ms, + ); + assert!(result.is_err()); + } + + // ==================== serialize/deserialize auth entry tests ==================== + + #[test] + fn test_serialize_and_deserialize_auth_entry() { + let params = create_test_params(); + let auth_entry = FeeForwarderService::::build_user_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + false, + ) + .unwrap(); + + let serialized = FeeForwarderService::::serialize_auth_entry(&auth_entry); + assert!(serialized.is_ok()); + + let xdr_string = serialized.unwrap(); + assert!(!xdr_string.is_empty()); + + let deserialized = + FeeForwarderService::::deserialize_auth_entry(&xdr_string); + assert!(deserialized.is_ok()); + } + + #[test] + fn test_deserialize_auth_entry_invalid_xdr() { + let result = + FeeForwarderService::::deserialize_auth_entry("not-valid-base64-xdr!"); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + FeeForwarderError::XdrError(_) + )); + } + + #[test] + fn test_deserialize_auth_entry_empty() { + let result = FeeForwarderService::::deserialize_auth_entry(""); + assert!(result.is_err()); + } + + // ==================== build_invoke_operation_standalone tests ==================== + + #[test] + fn test_build_invoke_operation_standalone() { + let params = create_test_params(); + let user_auth = FeeForwarderService::::build_user_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + false, + ) + .unwrap(); + let relayer_auth = + FeeForwarderService::::build_relayer_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + ) + .unwrap(); + + let result = FeeForwarderService::::build_invoke_operation_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + vec![user_auth, relayer_auth], + ); + assert!(result.is_ok()); + + let operation = result.unwrap(); + assert!(operation.source_account.is_none()); + match operation.body { + OperationBody::InvokeHostFunction(op) => { + assert_eq!(op.auth.len(), 2); + } + _ => panic!("Expected InvokeHostFunction operation"), + } + } + + #[test] + fn test_build_invoke_operation_standalone_invalid_fee_forwarder() { + let params = create_test_params(); + let result = FeeForwarderService::::build_invoke_operation_standalone( + "INVALID", + ¶ms, + vec![], + ); + assert!(result.is_err()); + } + + #[test] + fn test_build_invoke_operation_standalone_empty_auth() { + let params = create_test_params(); + let result = FeeForwarderService::::build_invoke_operation_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + vec![], + ); + assert!(result.is_ok()); + } + + // ==================== FeeForwarderParams tests ==================== + + #[test] + fn test_fee_forwarder_params_clone() { + let params = create_test_params(); + let cloned = params.clone(); + assert_eq!(params.fee_token, cloned.fee_token); + assert_eq!(params.fee_amount, cloned.fee_amount); + assert_eq!(params.max_fee_amount, cloned.max_fee_amount); + assert_eq!(params.expiration_ledger, cloned.expiration_ledger); + assert_eq!(params.target_contract, cloned.target_contract); + assert_eq!(params.target_fn, cloned.target_fn); + assert_eq!(params.user, cloned.user); + assert_eq!(params.relayer, cloned.relayer); + } + + #[test] + fn test_fee_forwarder_params_with_empty_target_args() { + let mut params = create_test_params(); + params.target_args = vec![]; + + let result = FeeForwarderService::::build_user_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + true, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_fee_forwarder_params_with_multiple_target_args() { + let mut params = create_test_params(); + params.target_args = vec![ + ScVal::U32(1), + ScVal::U32(2), + ScVal::U32(3), + ScVal::Bool(true), + ]; + + let result = FeeForwarderService::::build_user_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + true, + ); + assert!(result.is_ok()); + } + + // ==================== Error display tests ==================== + + #[test] + fn test_error_display_invalid_contract_address() { + let err = FeeForwarderError::InvalidContractAddress("test".to_string()); + assert!(err.to_string().contains("Invalid contract address")); + } + + #[test] + fn test_error_display_invalid_account_address() { + let err = FeeForwarderError::InvalidAccountAddress("test".to_string()); + assert!(err.to_string().contains("Invalid account address")); + } + + #[test] + fn test_error_display_authorization_build_error() { + let err = FeeForwarderError::AuthorizationBuildError("test".to_string()); + assert!(err.to_string().contains("Failed to build authorization")); + } + + #[test] + fn test_error_display_provider_error() { + let err = FeeForwarderError::ProviderError("test".to_string()); + assert!(err.to_string().contains("Provider error")); + } + + #[test] + fn test_error_display_xdr_error() { + let err = FeeForwarderError::XdrError("test".to_string()); + assert!(err.to_string().contains("XDR serialization error")); + } + + #[test] + fn test_error_display_invalid_function_name() { + let err = FeeForwarderError::InvalidFunctionName("test".to_string()); + assert!(err.to_string().contains("Invalid function name")); + } + + // ==================== Constants tests ==================== + + #[test] + fn test_default_validity_seconds() { + assert_eq!(DEFAULT_VALIDITY_SECONDS, 120); // 2 minutes, matches sponsored tx validity + } + + #[test] + fn test_ledger_time_seconds() { + assert_eq!(STELLAR_LEDGER_TIME_SECONDS, 5); + } +} diff --git a/src/utils/mocks.rs b/src/utils/mocks.rs index 957d1a9b9..00bf789cf 100644 --- a/src/utils/mocks.rs +++ b/src/utils/mocks.rs @@ -341,6 +341,14 @@ pub mod mockutils { request_timeout_seconds: 30, redis_reader_url: None, redis_reader_pool_max_size: 1000, + stellar_mainnet_fee_forwarder_address: None, + stellar_testnet_fee_forwarder_address: None, + stellar_mainnet_soroswap_router_address: None, + stellar_testnet_soroswap_router_address: None, + stellar_mainnet_soroswap_factory_address: None, + stellar_testnet_soroswap_factory_address: None, + stellar_mainnet_soroswap_native_wrapper_address: None, + stellar_testnet_soroswap_native_wrapper_address: None, } } }