diff --git a/.changelog/coinflow-credits.md b/.changelog/coinflow-credits.md new file mode 100644 index 00000000..022e52c7 --- /dev/null +++ b/.changelog/coinflow-credits.md @@ -0,0 +1,6 @@ +--- +tempo-common: minor +tempo-wallet: minor +--- + +Add wallet CLI support for checking and spending Coinflow credits, and update shared signer handling so wallet flows can emit the expected Tempo signature format for these transactions. diff --git a/.gitignore b/.gitignore index 5e382d78..dd95d817 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ keys.toml # Build artifacts *.tar.gz +.local/ # Eval (promptfoo output and old run artifacts) eval/runs/ diff --git a/Makefile b/Makefile index 3f7bb909..e831fb2d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build release clean check test fix install uninstall run coverage +.PHONY: build release clean check test fix install uninstall run coverage install-moderato-local build: cargo build @@ -20,6 +20,16 @@ install: release @$(HOME)/.tempo/bin/tempo-wallet --version @$(HOME)/.tempo/bin/tempo-request --version +install-moderato-local: + mkdir -p $(HOME)/.local/bin + ln -sf $(PWD)/scripts/tempo-wallet-moderato-local $(HOME)/.local/bin/tempo-wallet-moderato-local + ln -sf $(PWD)/scripts/tempo-request-moderato-local $(HOME)/.local/bin/tempo-request-moderato-local + chmod +x scripts/tempo-wallet-moderato-local scripts/tempo-request-moderato-local + @echo "" + @echo "Installed wrappers:" + @echo " $(HOME)/.local/bin/tempo-wallet-moderato-local" + @echo " $(HOME)/.local/bin/tempo-request-moderato-local" + uninstall: rm -f $(HOME)/.tempo/bin/tempo-wallet $(HOME)/.tempo/bin/tempo-request diff --git a/README.md b/README.md index a807c67b..fa6bc643 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,40 @@ tempo wallet sessions list tempo wallet sessions close https://openrouter.mpp.tempo.xyz ``` +## Local Moderato App Dev + +If you want a local wallet CLI instance that authenticates and funds against `https://app.moderato.tempo.local/`, install the repo wrappers: + +```bash +make build +make install-moderato-local +``` + +That gives you two commands: + +```bash +# Login/fund/spend credits against the local app +tempo-wallet-moderato-local login --no-browser +tempo-wallet-moderato-local fund --credits +tempo-wallet-moderato-local spend-credits --amount-cents 500 --to 0x... + +# Open MPP sessions and keep them in an isolated local wallet home +tempo-request-moderato-local -X POST \ + --json '{"model":"openai/gpt-4o-mini","messages":[{"role":"user","content":"hello"}]}' \ + https://openrouter.mpp.tempo.xyz/v1/chat/completions + +# Inspect or leave partially spent sessions open for local testing +tempo-wallet-moderato-local sessions list +``` + +The wrappers do three things for you automatically: + +1. Force the network to `tempo-moderato`. +2. Point `TEMPO_AUTH_URL` at `https://app.moderato.tempo.local/cli-auth`. +3. Keep wallet keys and `channels.db` under `wallet/.local/moderato-wallet`, so your normal wallet state is untouched and unclosed sessions persist for repeat testing. + +Reset that local dev wallet by deleting `wallet/.local/moderato-wallet`. If you want to use a different local app origin or RPC, override `TEMPO_MODERATO_APP_URL`, `TEMPO_AUTH_URL`, `TEMPO_HOME`, or `TEMPO_RPC_URL` before running the wrappers. + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for setup and workflow. diff --git a/crates/tempo-common/src/keys/signer.rs b/crates/tempo-common/src/keys/signer.rs index 5f136163..89baea21 100644 --- a/crates/tempo-common/src/keys/signer.rs +++ b/crates/tempo-common/src/keys/signer.rs @@ -4,16 +4,21 @@ //! resolves a network's key entry into a ready-to-use [`Signer`] //! (private key signer + signing mode + effective `from` address). -use alloy::{primitives::Address, signers::local::PrivateKeySigner}; +use alloy::{ + primitives::{Address, B256}, + signers::{local::PrivateKeySigner, SignerSync}, +}; use mpp::client::tempo::signing::{KeychainVersion, TempoSigningMode}; -use tempo_primitives::transaction::SignedKeyAuthorization; +use tempo_primitives::transaction::{ + KeychainSignature, PrimitiveSignature, SignedKeyAuthorization, TempoSignature, +}; use crate::{ error::{ConfigError, KeyError, TempoError}, network::NetworkId, }; -use super::{authorization, Keystore}; +use super::{authorization, KeyEntry, Keystore}; /// Parse a private key hex string into a `PrivateKeySigner`. /// @@ -52,6 +57,18 @@ pub struct Signer { } impl Signer { + fn effective_signing_hash(&self, hash: &B256) -> B256 { + match &self.signing_mode { + TempoSigningMode::Direct => *hash, + TempoSigningMode::Keychain { + wallet, version, .. + } => match version { + KeychainVersion::V1 => *hash, + KeychainVersion::V2 => KeychainSignature::signing_hash(*hash, *wallet), + }, + } + } + /// Returns a copy of this signer whose `signing_mode` includes the stored /// key authorization, so the next transaction atomically provisions the key. /// @@ -84,6 +101,158 @@ impl Signer { pub fn has_stored_key_authorization(&self) -> bool { self.stored_key_authorization.is_some() } + + /// Sign an arbitrary digest and return the raw inner signature bytes. + /// + /// Direct signers return the standard 65-byte secp256k1 signature over + /// `hash`. Keychain signers return the inner 65-byte secp256k1 signature + /// over the effective keychain signing hash, without the outer `0x03`/`0x04` + /// keychain envelope. This is the signature shape TIP-1020-style verifiers + /// expect after separately resolving the authorized access key. + /// + /// # Errors + /// + /// Returns an error when the underlying signing operation fails. + pub fn sign_hash_unwrapped_bytes( + &self, + hash: &B256, + operation: &'static str, + ) -> Result, TempoError> { + let hash_to_sign = self.effective_signing_hash(hash); + let signature = self + .signer + .sign_hash_sync(&hash_to_sign) + .map_err(|source| { + TempoError::from(KeyError::SigningOperationSource { + operation, + source: Box::new(source), + }) + })?; + Ok(signature.as_bytes().to_vec()) + } + + /// Sign an arbitrary digest and return the raw inner signature as a + /// 0x-prefixed hex string. + /// + /// # Errors + /// + /// Returns an error when the underlying signing operation fails. + pub fn sign_hash_unwrapped_hex( + &self, + hash: &B256, + operation: &'static str, + ) -> Result { + let bytes = self.sign_hash_unwrapped_bytes(hash, operation)?; + Ok(format!("0x{}", hex::encode(bytes))) + } + + /// Sign an arbitrary digest and return the serialized Tempo signature bytes. + /// + /// Direct signers return a raw 65-byte secp256k1 signature. Keychain + /// signers return a Tempo keychain envelope (type 0x03/0x04) wrapping the + /// inner secp256k1 signature for the configured wallet address. + /// + /// # Errors + /// + /// Returns an error when the underlying signing operation fails. + pub fn sign_hash_bytes( + &self, + hash: &B256, + operation: &'static str, + ) -> Result, TempoError> { + let hash_to_sign = self.effective_signing_hash(hash); + + let inner_signature = self + .signer + .sign_hash_sync(&hash_to_sign) + .map_err(|source| { + TempoError::from(KeyError::SigningOperationSource { + operation, + source: Box::new(source), + }) + })?; + + let signature = match &self.signing_mode { + TempoSigningMode::Direct => { + TempoSignature::Primitive(PrimitiveSignature::Secp256k1(inner_signature)) + } + TempoSigningMode::Keychain { + wallet, version, .. + } => { + let primitive = PrimitiveSignature::Secp256k1(inner_signature); + let keychain = match version { + KeychainVersion::V1 => KeychainSignature::new_v1(*wallet, primitive), + KeychainVersion::V2 => KeychainSignature::new(*wallet, primitive), + }; + TempoSignature::Keychain(keychain) + } + }; + + Ok(signature.to_bytes().to_vec()) + } + + /// Sign an arbitrary digest and return the serialized Tempo signature as a + /// 0x-prefixed hex string. + /// + /// # Errors + /// + /// Returns an error when the underlying signing operation fails. + pub fn sign_hash_hex( + &self, + hash: &B256, + operation: &'static str, + ) -> Result { + let bytes = self.sign_hash_bytes(hash, operation)?; + Ok(format!("0x{}", hex::encode(bytes))) + } +} + +fn signer_from_key_entry(key_entry: &KeyEntry) -> Result { + let pk = key_entry + .key + .as_deref() + .filter(|s| !s.is_empty()) + .ok_or_else(|| TempoError::from(ConfigError::Missing("No key configured.".to_string())))?; + let signer = parse_private_key_signer(pk)?; + + let wallet_address: Address = key_entry.wallet_address_parsed().ok_or_else(|| { + TempoError::from(ConfigError::InvalidAddress { + context: "wallet", + value: key_entry.wallet_address.clone(), + }) + })?; + + let (signing_mode, stored_key_authorization) = if wallet_address == signer.address() { + (TempoSigningMode::Direct, None) + } else { + // Decode the local key authorization but always start optimistically + // without it (assume key is already provisioned on-chain). + // The authorization is stored separately so callers can retry with + // `with_key_authorization()` if the key turns out not to be provisioned. + let local_auth = key_entry + .key_authorization + .as_deref() + .and_then(authorization::decode) + .map(Box::new); + + ( + TempoSigningMode::Keychain { + wallet: wallet_address, + key_authorization: None, + version: KeychainVersion::V2, + }, + local_auth, + ) + }; + + let from = signing_mode.from_address(signer.address()); + + Ok(Signer { + signer, + signing_mode, + from, + stored_key_authorization, + }) } impl Keystore { @@ -105,59 +274,82 @@ impl Keystore { ))) })?; - let pk = key_entry - .key - .as_deref() - .filter(|s| !s.is_empty()) + signer_from_key_entry(key_entry) + } + + /// Resolve the signer for a specific wallet on a network. + /// + /// Matches an exact wallet+network entry first, then falls back to a + /// direct EOA entry for the same wallet because those keys can sign on any + /// network. + /// + /// # Errors + /// + /// Returns an error when no key is configured for `wallet_address` on + /// `network`, stored addresses are malformed, or signer parsing fails. + pub fn signer_for_wallet_address( + &self, + wallet_address: Address, + network: NetworkId, + ) -> Result { + let key_entry = self + .key_for_wallet_address_and_network(wallet_address, network) + .or_else(|| { + self.keys.iter().find(|key| { + key.wallet_address_matches(wallet_address) && key.is_direct_eoa_key() + }) + }) .ok_or_else(|| { - TempoError::from(ConfigError::Missing("No key configured.".to_string())) + TempoError::from(ConfigError::Missing(format!( + "No key configured for wallet '{wallet_address:#x}' on network '{}'.", + network.as_str() + ))) })?; - let signer = parse_private_key_signer(pk)?; - let wallet_address: Address = key_entry.wallet_address_parsed().ok_or_else(|| { - TempoError::from(ConfigError::InvalidAddress { - context: "wallet", - value: key_entry.wallet_address.clone(), - }) - })?; + signer_from_key_entry(key_entry) + } - let (signing_mode, stored_key_authorization) = if wallet_address == signer.address() { - (TempoSigningMode::Direct, None) - } else { - // Decode the local key authorization but always start optimistically - // without it (assume key is already provisioned on-chain). - // The authorization is stored separately so callers can retry with - // `with_key_authorization()` if the key turns out not to be provisioned. - let local_auth = key_entry - .key_authorization - .as_deref() - .and_then(authorization::decode) - .map(Box::new); - - ( - TempoSigningMode::Keychain { - wallet: wallet_address, - key_authorization: None, - version: KeychainVersion::V2, - }, - local_auth, - ) - }; + /// Resolve a signer for either a wallet address or an access-key address. + /// + /// If `address` matches a stored wallet address, this behaves like + /// [`signer_for_wallet_address`](Self::signer_for_wallet_address). If it + /// matches a stored `key_address`, it returns a direct signer for that key + /// identity so commands can operate on balances that are intentionally held + /// on the access key itself. + pub fn signer_for_identity_address( + &self, + address: Address, + network: NetworkId, + ) -> Result { + if let Ok(signer) = self.signer_for_wallet_address(address, network) { + return Ok(signer); + } - let from = signing_mode.from_address(signer.address()); + let chain_id = network.chain_id(); + let key_entry = self + .keys + .iter() + .find(|key| key.key_address_matches(address) && key.chain_id == chain_id) + .ok_or_else(|| { + TempoError::from(ConfigError::Missing(format!( + "No key configured for identity '{address:#x}' on network '{}'.", + network.as_str() + ))) + })?; - Ok(Signer { - signer, - signing_mode, - from, - stored_key_authorization, - }) + let mut direct_entry = key_entry.clone(); + direct_entry.set_wallet_address(address); + direct_entry.set_key_address(Some(address)); + direct_entry.key_authorization = None; + + signer_from_key_entry(&direct_entry) } } #[cfg(test)] mod tests { use super::*; + use alloy::primitives::keccak256; use zeroize::Zeroizing; use crate::keys::KeyEntry; @@ -198,6 +390,90 @@ mod tests { } } + #[test] + fn test_signer_for_identity_address_uses_direct_signer_for_key_address() { + let mut keys = Keystore::default(); + let key_address: Address = TEST_ADDRESS.parse().unwrap(); + keys.keys.push(KeyEntry { + wallet_address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8".to_string(), + key_address: Some(TEST_ADDRESS.to_string()), + key: Some(Zeroizing::new(TEST_PRIVATE_KEY.to_string())), + chain_id: NetworkId::TempoModerato.chain_id(), + ..Default::default() + }); + + let signer = keys + .signer_for_identity_address(key_address, NetworkId::TempoModerato) + .unwrap(); + + assert!(matches!(signer.signing_mode, TempoSigningMode::Direct)); + assert_eq!(signer.from, key_address); + assert_eq!(signer.signer.address(), key_address); + } + + #[test] + fn test_signer_for_wallet_address_selects_requested_wallet_key() { + const SECOND_PRIVATE_KEY: &str = + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; + + let requested_wallet: Address = "0x1111111111111111111111111111111111111111" + .parse() + .unwrap(); + let other_wallet: Address = "0x2222222222222222222222222222222222222222" + .parse() + .unwrap(); + let other_signer_address = parse_private_key_signer(SECOND_PRIVATE_KEY) + .unwrap() + .address(); + + let mut keys = Keystore::default(); + keys.keys.push(KeyEntry { + wallet_address: format!("{other_wallet:#x}"), + key_address: Some(format!("{other_signer_address:#x}")), + key: Some(Zeroizing::new(SECOND_PRIVATE_KEY.to_string())), + chain_id: NetworkId::TempoModerato.chain_id(), + ..Default::default() + }); + keys.keys.push(KeyEntry { + wallet_address: format!("{requested_wallet:#x}"), + key_address: Some(TEST_ADDRESS.to_string()), + key: Some(Zeroizing::new(TEST_PRIVATE_KEY.to_string())), + chain_id: NetworkId::TempoModerato.chain_id(), + ..Default::default() + }); + + let default_signer = keys.signer(NetworkId::TempoModerato).unwrap(); + assert_eq!(default_signer.signer.address(), other_signer_address); + + let signer = keys + .signer_for_wallet_address(requested_wallet, NetworkId::TempoModerato) + .unwrap(); + + assert_eq!( + signer.signer.address(), + TEST_ADDRESS.parse::
().unwrap() + ); + match signer.signing_mode { + TempoSigningMode::Keychain { wallet, .. } => { + assert_eq!(wallet, requested_wallet); + } + TempoSigningMode::Direct => panic!("expected Keychain mode"), + } + } + + #[test] + fn test_signer_for_wallet_address_direct_eoa_falls_back_across_networks() { + let keys = Keystore::from_private_key(TEST_PRIVATE_KEY).unwrap(); + let wallet_address = keys.wallet_address_parsed().unwrap(); + + let signer = keys + .signer_for_wallet_address(wallet_address, NetworkId::TempoModerato) + .unwrap(); + + assert!(matches!(signer.signing_mode, TempoSigningMode::Direct)); + assert_eq!(signer.signer.address(), wallet_address); + } + #[test] fn test_signer_keychain_always_omits_auth_from_signing_mode() { let mut keys = Keystore::default(); @@ -300,6 +576,101 @@ mod tests { assert!(signer.with_key_authorization().is_none()); } + #[test] + fn test_sign_hash_hex_direct_returns_raw_signature() { + let keys = Keystore::from_private_key(TEST_PRIVATE_KEY).unwrap(); + let signer = keys.signer(NetworkId::Tempo).unwrap(); + let hash = keccak256(b"coinflow-direct"); + + let signature_hex = signer + .sign_hash_hex(&hash, "sign direct test hash") + .unwrap(); + let bytes = hex::decode(signature_hex.trim_start_matches("0x")).unwrap(); + let signature = TempoSignature::from_bytes(&bytes).unwrap(); + + assert!(matches!(signature, TempoSignature::Primitive(_))); + assert_eq!( + signature.recover_signer(&hash).unwrap(), + signer.signer.address() + ); + } + + #[test] + fn test_sign_hash_hex_keychain_returns_v2_envelope() { + let mut keys = Keystore::default(); + let wallet_address: Address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + .parse() + .unwrap(); + keys.keys.push(KeyEntry { + wallet_address: format!("{wallet_address:#x}"), + key_address: Some(TEST_ADDRESS.to_string()), + key: Some(Zeroizing::new(TEST_PRIVATE_KEY.to_string())), + chain_id: 4217, + ..Default::default() + }); + let signer = keys.signer(NetworkId::Tempo).unwrap(); + let hash = keccak256(b"coinflow-keychain"); + + let signature_hex = signer + .sign_hash_hex(&hash, "sign keychain test hash") + .unwrap(); + let bytes = hex::decode(signature_hex.trim_start_matches("0x")).unwrap(); + let signature = TempoSignature::from_bytes(&bytes).unwrap(); + let keychain = signature + .as_keychain() + .expect("expected keychain signature"); + + assert_eq!(bytes[0], 0x04, "expected V2 keychain type byte"); + assert_eq!(keychain.user_address, wallet_address); + assert_eq!(signature.recover_signer(&hash).unwrap(), wallet_address); + assert_eq!(keychain.key_id(&hash).unwrap(), signer.signer.address()); + } + + #[test] + fn test_sign_hash_unwrapped_hex_keychain_returns_raw_inner_signature() { + let mut keys = Keystore::default(); + let wallet_address: Address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + .parse() + .unwrap(); + keys.keys.push(KeyEntry { + wallet_address: format!("{wallet_address:#x}"), + key_address: Some(TEST_ADDRESS.to_string()), + key: Some(Zeroizing::new(TEST_PRIVATE_KEY.to_string())), + chain_id: 4217, + ..Default::default() + }); + let signer = keys.signer(NetworkId::Tempo).unwrap(); + let hash = keccak256(b"coinflow-keychain-tip1020"); + + let signature_hex = signer + .sign_hash_unwrapped_hex(&hash, "sign keychain test hash for tip-1020") + .unwrap(); + let bytes = hex::decode(signature_hex.trim_start_matches("0x")).unwrap(); + let signature = TempoSignature::from_bytes(&bytes).unwrap(); + + assert_eq!(bytes.len(), 65, "tip-1020 expects the raw inner signature"); + assert!(matches!(signature, TempoSignature::Primitive(_))); + let effective_hash = KeychainSignature::signing_hash(hash, wallet_address); + assert_eq!( + signature.recover_signer(&effective_hash).unwrap(), + signer.signer.address() + ); + } + + #[test] + fn test_sign_hash_unwrapped_hex_direct_matches_wrapped_output() { + let keys = Keystore::from_private_key(TEST_PRIVATE_KEY).unwrap(); + let signer = keys.signer(NetworkId::Tempo).unwrap(); + let hash = keccak256(b"coinflow-direct-tip1020"); + + let wrapped = signer.sign_hash_hex(&hash, "sign direct hash").unwrap(); + let unwrapped = signer + .sign_hash_unwrapped_hex(&hash, "sign direct hash for tip-1020") + .unwrap(); + + assert_eq!(wrapped, unwrapped); + } + #[test] fn test_signer_no_key_for_network() { let keys = Keystore::default(); diff --git a/crates/tempo-wallet/src/analytics.rs b/crates/tempo-wallet/src/analytics.rs index 57d614d8..5a6d392e 100644 --- a/crates/tempo-wallet/src/analytics.rs +++ b/crates/tempo-wallet/src/analytics.rs @@ -36,11 +36,13 @@ pub(crate) struct WalletCreatedPayload { pub(crate) struct WalletFundPayload { pub(crate) network: String, pub(crate) method: String, + pub(crate) target: String, } #[derive(Debug, Clone, Serialize)] pub(crate) struct WalletFundFailurePayload { pub(crate) network: String, pub(crate) method: String, + pub(crate) target: String, pub(crate) error: String, } diff --git a/crates/tempo-wallet/src/app.rs b/crates/tempo-wallet/src/app.rs index 38fb104c..bdb940f8 100644 --- a/crates/tempo-wallet/src/app.rs +++ b/crates/tempo-wallet/src/app.rs @@ -3,8 +3,8 @@ use crate::{ args::{Cli, Commands, ServicesCommands, SessionCommands}, commands::{ - completions, debug, fund, keys, login, logout, refresh, services, sessions, transfer, - whoami, + completions, credits, debug, fund, keys, login, logout, refresh, services, sessions, + spend_credits, transfer, whoami, }, }; use tempo_common::error::TempoError; @@ -32,7 +32,21 @@ pub(crate) async fn run(mut cli: Cli) -> Result<(), TempoError> { Commands::Fund { address, no_browser, - } => fund::run(&ctx, address, no_browser).await, + crypto, + credits, + referral_code, + } => { + let target = fund::Target::from_cli(crypto, credits, referral_code); + fund::run(&ctx, address, no_browser, target).await + } + Commands::Credits { address } => credits::run(&ctx, address).await, + Commands::SpendCredits { + amount_cents, + to, + data, + value, + address, + } => spend_credits::run(&ctx, amount_cents, to, data, value, address).await, Commands::Whoami => whoami::run(&ctx).await, Commands::Keys => keys::run(&ctx).await, Commands::Sessions { command } => { @@ -71,6 +85,8 @@ const fn command_name(command: &Commands) -> &'static str { Commands::Logout { .. } => "logout", Commands::Completions { .. } => "completions", Commands::Fund { .. } => "fund", + Commands::Credits { .. } => "credits", + Commands::SpendCredits { .. } => "spend-credits", Commands::Whoami => "whoami", Commands::Keys => "keys", Commands::Sessions { command } => match command { diff --git a/crates/tempo-wallet/src/args.rs b/crates/tempo-wallet/src/args.rs index e7438462..c53a7041 100644 --- a/crates/tempo-wallet/src/args.rs +++ b/crates/tempo-wallet/src/args.rs @@ -72,7 +72,7 @@ Examples: #[arg(long)] dry_run: bool, }, - /// Fund your wallet (testnet faucet or mainnet bridge) + /// Open add-funds flows in the wallet app #[command(display_order = 7, name = "fund")] Fund { /// Wallet address to fund (defaults to current wallet) @@ -81,16 +81,60 @@ Examples: /// Do not attempt to open a browser #[arg(long)] no_browser: bool, + /// Open the direct crypto funding flow (bridge on mainnet, faucet on testnet) + #[arg(long, conflicts_with_all = ["credits", "referral_code"])] + crypto: bool, + /// Open the credits purchase flow + #[arg(long, conflicts_with_all = ["crypto", "referral_code"])] + credits: bool, + /// Open the referral-code redeem flow with a prefilled code + #[arg( + long, + value_name = "CODE", + visible_alias = "claim", + conflicts_with_all = ["crypto", "credits"] + )] + referral_code: Option, + }, + /// Show the current credits balance + #[command(display_order = 8, name = "credits")] + Credits { + /// Wallet address to inspect (defaults to current wallet) + #[arg(long)] + address: Option, + }, + /// Spend credits via Coinflow redeem + #[command( + display_order = 9, + name = "spend-credits", + arg_required_else_help = true + )] + SpendCredits { + /// Amount in USD cents (e.g. 500 = $5.00) + #[arg(long)] + amount_cents: u64, + /// Target contract address (0x...) + #[arg(long)] + to: String, + /// Calldata hex (0x...) + #[arg(long, default_value = "0x")] + data: String, + /// ETH value in wei (default: 0) + #[arg(long, default_value = "0")] + value: String, + /// Wallet address (defaults to current wallet) + #[arg(long)] + address: Option, }, /// Manage payment sessions - #[command(display_order = 8, name = "sessions")] + #[command(display_order = 10, name = "sessions")] #[command(args_conflicts_with_subcommands = true)] Sessions { #[command(subcommand)] command: Option, }, /// Browse the MPP service directory - #[command(display_order = 9, name = "services")] + #[command(display_order = 11, name = "services")] Services { #[command(subcommand)] command: Option, @@ -105,7 +149,7 @@ Examples: }, /// Collect debug info for support - #[command(display_order = 10)] + #[command(display_order = 12)] Debug, /// Generate shell completions script diff --git a/crates/tempo-wallet/src/commands/credits.rs b/crates/tempo-wallet/src/commands/credits.rs new file mode 100644 index 00000000..a7d57747 --- /dev/null +++ b/crates/tempo-wallet/src/commands/credits.rs @@ -0,0 +1,43 @@ +//! Credits balance lookup. + +use std::io::Write; + +use serde::Serialize; + +use crate::commands::fund; +use tempo_common::{ + cli::{context::Context, output, output::OutputFormat}, + error::TempoError, +}; + +#[derive(Debug, Serialize)] +struct CreditsResponse { + wallet: String, + balance: String, + raw_balance: String, +} + +pub(crate) async fn run(ctx: &Context, address: Option) -> Result<(), TempoError> { + let auth_server_url = + std::env::var("TEMPO_AUTH_URL").unwrap_or_else(|_| ctx.network.auth_url().to_string()); + let wallet = fund::resolve_credit_address(address, &ctx.keys)?; + let raw_balance = fund::query_credit_balance(&auth_server_url, &wallet).await?; + let response = CreditsResponse { + wallet, + balance: fund::format_credit_balance(raw_balance), + raw_balance: raw_balance.to_string(), + }; + + response.render(ctx.output_format) +} + +impl CreditsResponse { + fn render(&self, format: OutputFormat) -> Result<(), TempoError> { + output::emit_by_format(format, self, || { + let w = &mut std::io::stdout(); + writeln!(w, "{:>10}: {}", "Wallet", self.wallet)?; + writeln!(w, "{:>10}: {}", "Credits", self.balance)?; + Ok(()) + }) + } +} diff --git a/crates/tempo-wallet/src/commands/fund/mod.rs b/crates/tempo-wallet/src/commands/fund/mod.rs index 9d5b638b..3649618f 100644 --- a/crates/tempo-wallet/src/commands/fund/mod.rs +++ b/crates/tempo-wallet/src/commands/fund/mod.rs @@ -1,7 +1,9 @@ -//! Fund command — open browser to fund your Tempo wallet. +//! Fund command — open browser deeplinks for adding funds to your Tempo wallet. use std::time::{Duration, Instant}; +use alloy::primitives::U256; +use serde::Deserialize; use url::Url; use crate::{ @@ -11,7 +13,7 @@ use crate::{ }; use tempo_common::{ cli::{context::Context, output::OutputFormat}, - error::{ConfigError, InputError, TempoError}, + error::{ConfigError, InputError, NetworkError, TempoError}, keys::Keystore, security::sanitize_error, }; @@ -22,6 +24,123 @@ const POLL_INTERVAL_SECS: u64 = 3; /// Maximum time to wait for balance change (seconds). const CALLBACK_TIMEOUT_SECS: u64 = 900; +/// Raw contract units per user-visible credit. +const RAW_TO_CREDITS: u64 = 10_000; + +#[derive(Debug)] +enum CompletionWatch { + Token { + wallet_address: String, + before: Vec, + }, + Credits { + wallet_address: String, + auth_server_url: String, + before_raw: U256, + }, +} + +impl CompletionWatch { + const fn waiting_message(&self) -> &'static str { + match self { + Self::Token { .. } => "Waiting for funding...", + Self::Credits { .. } => "Waiting for credits...", + } + } + + const fn timeout_subject(&self) -> &'static str { + match self { + Self::Token { .. } => "funding", + Self::Credits { .. } => "credits", + } + } +} + +#[derive(Debug, Deserialize)] +struct CoinflowBalancesResponse { + credits: CoinflowBalance, +} + +#[derive(Debug, Deserialize)] +struct CoinflowBalance { + #[serde(default, rename = "rawAmount")] + raw_amount: Option, + #[serde(default)] + cents: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum CoinflowAmount { + Number(u64), + String(String), +} + +impl CoinflowAmount { + fn as_u256(&self, field: &'static str) -> Result { + match self { + Self::Number(value) => Ok(U256::from(*value)), + Self::String(value) => { + value + .trim() + .parse::() + .map_err(|_| NetworkError::ResponseSchema { + context: "coinflow balances response", + reason: format!("invalid {field}: {value}"), + }) + } + } + } +} + +impl CoinflowBalance { + fn raw_balance(&self) -> Result { + if let Some(raw_amount) = &self.raw_amount { + return raw_amount.as_u256("credits.rawAmount"); + } + + if let Some(cents) = &self.cents { + return Ok(cents.as_u256("credits.cents")? * U256::from(RAW_TO_CREDITS / 100)); + } + + Err(NetworkError::ResponseMissingField { + context: "coinflow balances response", + field: "credits.rawAmount or credits.cents", + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Target { + Fund, + Crypto, + Credits, + ReferralCode(String), +} + +impl Target { + pub(crate) fn from_cli(crypto: bool, credits: bool, referral_code: Option) -> Self { + if let Some(code) = referral_code { + Self::ReferralCode(code) + } else if credits { + Self::Credits + } else if crypto { + Self::Crypto + } else { + Self::Fund + } + } + + const fn analytics_target(&self) -> &'static str { + match self { + Self::Fund => "fund", + Self::Crypto => "crypto", + Self::Credits => "credits", + Self::ReferralCode(_) => "referral", + } + } +} + // --------------------------------------------------------------------------- // Entry point // --------------------------------------------------------------------------- @@ -30,11 +149,12 @@ pub(crate) async fn run( ctx: &Context, address: Option, no_browser: bool, + target: Target, ) -> Result<(), TempoError> { let method = fund_method(no_browser); - track_fund_start(ctx, method); - let result = run_inner(ctx, address, no_browser).await; - track_fund_result(ctx, method, &result); + track_fund_start(ctx, method, target.analytics_target()); + let result = run_inner(ctx, address, no_browser, &target).await; + track_fund_result(ctx, method, target.analytics_target(), &result); result } @@ -42,20 +162,12 @@ async fn run_inner( ctx: &Context, address: Option, no_browser: bool, + target: &Target, ) -> Result<(), TempoError> { - let wallet_address = resolve_address(address, &ctx.keys)?; - - let before = query_all_balances(&ctx.config, ctx.network, &wallet_address).await; - let auth_server_url = std::env::var("TEMPO_AUTH_URL").unwrap_or_else(|_| ctx.network.auth_url().to_string()); - - let parsed_url = Url::parse(&auth_server_url).map_err(|source| InputError::UrlParseFor { - context: "auth server", - source, - })?; - let base_url = parsed_url.origin().ascii_serialization(); - let fund_url = format!("{base_url}/?action=fund"); + let completion_watch = prepare_completion_watch(ctx, address, target, &auth_server_url).await?; + let fund_url = build_fund_url(&auth_server_url, target)?; let show_status = no_browser || ctx.output_format == OutputFormat::Text; if show_status { @@ -65,13 +177,50 @@ async fn run_inner( super::auth::try_open_browser(&fund_url, no_browser); if no_browser { - show_remote_fund_prompt(&fund_url); + show_remote_fund_prompt(target, &fund_url); } if show_status { - eprintln!("Waiting for funding..."); + eprintln!("{}", completion_watch.waiting_message()); } + wait_for_completion(ctx, &completion_watch, show_status).await +} + +async fn prepare_completion_watch( + ctx: &Context, + address: Option, + target: &Target, + auth_server_url: &str, +) -> Result { + match target { + Target::Credits => { + let wallet_address = resolve_credit_address(address, &ctx.keys)?; + let before_raw = query_credit_balance(auth_server_url, &wallet_address).await?; + + Ok(CompletionWatch::Credits { + wallet_address, + auth_server_url: auth_server_url.to_string(), + before_raw, + }) + } + Target::Fund | Target::Crypto | Target::ReferralCode(_) => { + let wallet_address = resolve_address(address, &ctx.keys)?; + let before = query_all_balances(&ctx.config, ctx.network, &wallet_address).await; + + Ok(CompletionWatch::Token { + wallet_address, + before, + }) + } + } +} + +async fn wait_for_completion( + ctx: &Context, + completion_watch: &CompletionWatch, + show_status: bool, +) -> Result<(), TempoError> { let start = Instant::now(); let timeout = Duration::from_secs(CALLBACK_TIMEOUT_SECS); let interval = Duration::from_secs(POLL_INTERVAL_SECS); @@ -80,7 +229,8 @@ async fn run_inner( if start.elapsed() >= timeout { if show_status { eprintln!( - "Timed out waiting for funding after {} minutes.", + "Timed out waiting for {} after {} minutes.", + completion_watch.timeout_subject(), CALLBACK_TIMEOUT_SECS / 60 ); } @@ -89,14 +239,36 @@ async fn run_inner( tokio::time::sleep(interval).await; - let current = query_all_balances(&ctx.config, ctx.network, &wallet_address).await; - - if has_balance_changed(&before, ¤t) { - if show_status { - eprintln!("\nFunding received!"); - render_balance_diff(&before, ¤t); + match completion_watch { + CompletionWatch::Token { + wallet_address, + before, + } => { + let current = query_all_balances(&ctx.config, ctx.network, wallet_address).await; + + if has_balance_changed(before, ¤t) { + if show_status { + eprintln!("\nFunding received!"); + render_balance_diff(before, ¤t); + } + return Ok(()); + } + } + CompletionWatch::Credits { + wallet_address, + auth_server_url, + before_raw, + } => { + let current_raw = query_credit_balance(auth_server_url, wallet_address).await?; + + if current_raw > *before_raw { + if show_status { + eprintln!("\nCredits received!"); + render_credit_balance_diff(*before_raw, current_raw); + } + return Ok(()); + } } - return Ok(()); } } } @@ -106,7 +278,10 @@ async fn run_inner( // --------------------------------------------------------------------------- /// Resolve the target wallet address from an explicit arg or the keystore default. -fn resolve_address(address: Option, keys: &Keystore) -> Result { +pub(crate) fn resolve_address( + address: Option, + keys: &Keystore, +) -> Result { if let Some(addr) = address { let parsed = tempo_common::security::parse_address_input(&addr, "wallet address")?; return Ok(format!("{parsed:#x}")); @@ -117,6 +292,110 @@ fn resolve_address(address: Option, keys: &Keystore) -> Result, + keys: &Keystore, +) -> Result { + if let Some(addr) = address { + let parsed = tempo_common::security::parse_address_input(&addr, "wallet address")?; + return Ok(format!("{parsed:#x}")); + } + + keys.primary_key() + .and_then(|entry| { + entry + .key_address_hex() + .or_else(|| entry.wallet_address_hex()) + }) + .ok_or_else(|| { + ConfigError::Missing("No wallet configured. Run 'tempo wallet login'.".to_string()) + .into() + }) +} + +fn build_fund_url(auth_server_url: &str, target: &Target) -> Result { + let mut url = Url::parse(auth_server_url).map_err(|source| InputError::UrlParseFor { + context: "auth server", + source, + })?; + + url.set_path("/"); + url.set_query(None); + + { + let mut query = url.query_pairs_mut(); + match target { + Target::Fund => { + query.append_pair("action", "fund"); + } + Target::Crypto => { + query.append_pair("action", "crypto"); + } + Target::Credits => { + query.append_pair("action", "credits"); + } + Target::ReferralCode(code) => { + query.append_pair("claim", code); + } + } + } + + Ok(url.to_string()) +} + +fn build_coinflow_balances_url( + auth_server_url: &str, + wallet_address: &str, +) -> Result { + let mut url = Url::parse(auth_server_url).map_err(|source| InputError::UrlParseFor { + context: "auth server", + source, + })?; + + url.set_path("/api/coinflow/balances"); + url.set_query(None); + url.query_pairs_mut().append_pair("wallet", wallet_address); + + Ok(url.to_string()) +} + +pub(crate) async fn query_credit_balance( + auth_server_url: &str, + wallet_address: &str, +) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .user_agent(format!("tempo-wallet/{}", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(NetworkError::Reqwest)?; + let url = build_coinflow_balances_url(auth_server_url, wallet_address)?; + let resp = client + .get(url) + .send() + .await + .map_err(NetworkError::Reqwest)?; + + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.ok(); + return Err(NetworkError::HttpStatus { + operation: "fetch coinflow balances", + status: status.as_u16(), + body, + } + .into()); + } + + let body = resp.text().await.map_err(NetworkError::Reqwest)?; + let balances: CoinflowBalancesResponse = + serde_json::from_str(&body).map_err(|source| NetworkError::ResponseParse { + context: "coinflow balances response", + source, + })?; + + Ok(balances.credits.raw_balance()?) +} + /// Returns `true` if any token balance differs between `initial` and `current`. fn has_balance_changed(initial: &[TokenBalance], current: &[TokenBalance]) -> bool { if current.len() != initial.len() { @@ -146,6 +425,26 @@ fn render_balance_diff(before: &[TokenBalance], after: &[TokenBalance]) { } } +fn render_credit_balance_diff(before_raw: U256, after_raw: U256) { + eprintln!( + " Credit balance: {} -> {}", + format_credit_balance(before_raw), + format_credit_balance(after_raw) + ); +} + +pub(crate) fn format_credit_balance(raw: U256) -> String { + let divisor = U256::from(RAW_TO_CREDITS); + let whole = raw / divisor; + let fractional = u64::try_from(raw % divisor).expect("fractional credits fit in u64"); + + if fractional == 0 { + whole.to_string() + } else { + format!("{whole}.{fractional:04}") + } +} + fn fund_method(no_browser: bool) -> &'static str { if no_browser { "manual" @@ -154,26 +453,35 @@ fn fund_method(no_browser: bool) -> &'static str { } } -fn show_remote_fund_prompt(fund_url: &str) { +fn show_remote_fund_prompt(target: &Target, fund_url: &str) { eprintln!("Open this link on your device: {fund_url}"); - eprintln!("After funding is complete, return here to continue."); + match target { + Target::Credits => { + eprintln!("Complete the credits purchase in the wallet app."); + eprintln!("After purchasing credits, return here to continue."); + } + Target::Fund | Target::Crypto | Target::ReferralCode(_) => { + eprintln!("After funding is complete, return here to continue."); + } + } } // --------------------------------------------------------------------------- // Analytics // --------------------------------------------------------------------------- -fn track_fund_start(ctx: &Context, method: &str) { +fn track_fund_start(ctx: &Context, method: &str, target: &str) { ctx.track( analytics::WALLET_FUND_STARTED, WalletFundPayload { network: ctx.network.as_str().to_string(), method: method.to_string(), + target: target.to_string(), }, ); } -fn track_fund_result(ctx: &Context, method: &str, result: &Result<(), TempoError>) { +fn track_fund_result(ctx: &Context, method: &str, target: &str, result: &Result<(), TempoError>) { match result { Ok(()) => { ctx.track( @@ -181,6 +489,7 @@ fn track_fund_result(ctx: &Context, method: &str, result: &Result<(), TempoError WalletFundPayload { network: ctx.network.as_str().to_string(), method: method.to_string(), + target: target.to_string(), }, ); } @@ -190,6 +499,7 @@ fn track_fund_result(ctx: &Context, method: &str, result: &Result<(), TempoError WalletFundFailurePayload { network: ctx.network.as_str().to_string(), method: method.to_string(), + target: target.to_string(), error: sanitize_error(&e.to_string()), }, ); @@ -199,11 +509,52 @@ fn track_fund_result(ctx: &Context, method: &str, result: &Result<(), TempoError #[cfg(test)] mod tests { - use super::fund_method; + use super::{build_coinflow_balances_url, build_fund_url, fund_method, Target}; #[test] fn fund_method_uses_manual_only_when_no_browser_is_true() { assert_eq!(fund_method(true), "manual"); assert_eq!(fund_method(false), "browser"); } + + #[test] + fn build_fund_url_uses_expected_query_for_each_target() { + assert_eq!( + build_fund_url("https://wallet.moderato.tempo.xyz/cli-auth", &Target::Fund).unwrap(), + "https://wallet.moderato.tempo.xyz/?action=fund" + ); + assert_eq!( + build_fund_url( + "https://wallet.moderato.tempo.xyz/cli-auth", + &Target::Crypto + ) + .unwrap(), + "https://wallet.moderato.tempo.xyz/?action=crypto" + ); + assert_eq!( + build_fund_url( + "https://wallet.moderato.tempo.xyz/cli-auth", + &Target::Credits + ) + .unwrap(), + "https://wallet.moderato.tempo.xyz/?action=credits" + ); + assert_eq!( + build_fund_url( + "https://wallet.moderato.tempo.xyz/cli-auth", + &Target::ReferralCode("ABC123".to_string()) + ) + .unwrap(), + "https://wallet.moderato.tempo.xyz/?claim=ABC123" + ); + } + + #[test] + fn build_coinflow_balances_url_uses_wallet_root() { + assert_eq!( + build_coinflow_balances_url("https://wallet.moderato.tempo.xyz/cli-auth", "0x1234") + .unwrap(), + "https://wallet.moderato.tempo.xyz/api/coinflow/balances?wallet=0x1234" + ); + } } diff --git a/crates/tempo-wallet/src/commands/mod.rs b/crates/tempo-wallet/src/commands/mod.rs index cd99fca0..0146965b 100644 --- a/crates/tempo-wallet/src/commands/mod.rs +++ b/crates/tempo-wallet/src/commands/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod auth; pub(crate) mod completions; +pub(crate) mod credits; pub(crate) mod debug; pub(crate) mod fund; pub(crate) mod keys; @@ -10,5 +11,6 @@ pub(crate) mod logout; pub(crate) mod refresh; pub(crate) mod services; pub(crate) mod sessions; +pub(crate) mod spend_credits; pub(crate) mod transfer; pub(crate) mod whoami; diff --git a/crates/tempo-wallet/src/commands/spend_credits.rs b/crates/tempo-wallet/src/commands/spend_credits.rs new file mode 100644 index 00000000..bac59d86 --- /dev/null +++ b/crates/tempo-wallet/src/commands/spend_credits.rs @@ -0,0 +1,878 @@ +//! Spend credits via Coinflow redeem flow. + +use std::{io::Write, time::Duration}; + +use alloy::primitives::{keccak256, Address, B256}; +use mpp::client::tempo::signing::{KeychainVersion, TempoSigningMode}; +use serde::{Deserialize, Serialize}; +use tempo_primitives::transaction::{KeychainSignature, TempoSignature}; + +use crate::commands::fund; +use tempo_common::{ + cli::{context::Context, output, output::OutputFormat}, + error::{ConfigError, InputError, NetworkError, TempoError}, + keys::Signer, +}; + +const COINFLOW_BLOCKCHAIN: &str = "tempo"; +const COINFLOW_AUTH_SUBTOTAL_RETRY_BUFFER_CENTS: u64 = 1; + +#[derive(Debug, Deserialize)] +struct AuthMsgResponse { + message: String, + #[serde(rename = "validBefore")] + valid_before: String, + nonce: String, + #[serde(rename = "creditsRawAmount")] + credits_raw_amount: u64, +} + +#[derive(Debug, Deserialize)] +struct RedeemResponse { + hash: String, +} + +#[derive(Debug, Serialize)] +struct SpendCreditsResult { + wallet: String, + amount_cents: u64, + tx_hash: String, +} + +#[derive(Debug, Serialize)] +struct SignatureDebugInfo { + eip712_digest: String, + effective_signing_hash: String, + effective_signing_hash_kind: &'static str, + signature_length_bytes: usize, + keychain_embedded_wallet_address: Option, + keychain_inner_signer_from_eip712_digest: Option, + recovered_from_eip712_digest: Option, + recovered_from_effective_signing_hash: Option, + expected_signer_address: String, + expected_wallet_address: String, + matches_keychain_wallet_address: bool, + matches_keychain_inner_signer_on_eip712_digest: bool, + matches_signer_on_eip712_digest: bool, + matches_signer_on_effective_signing_hash: bool, + matches_wallet_on_eip712_digest: bool, + matches_wallet_on_effective_signing_hash: bool, +} + +struct SubmitRedeemParams<'a> { + base_url: &'a str, + wallet: &'a str, + amount_cents: u64, + transaction_data: &'a serde_json::Value, + auth_resp: &'a AuthMsgResponse, + signature: &'a str, + signature_debug: &'a SignatureDebugInfo, + signer_info: &'a Signer, + output_format: OutputFormat, +} + +pub(crate) async fn run( + ctx: &Context, + amount_cents: u64, + to: String, + data: String, + value: String, + address: Option, +) -> Result<(), TempoError> { + let auth_server_url = + std::env::var("TEMPO_AUTH_URL").unwrap_or_else(|_| ctx.network.auth_url().to_string()); + let wallet = fund::resolve_credit_address(address, &ctx.keys)?; + let wallet_address = tempo_common::security::parse_address_input(&wallet, "wallet address")?; + let transaction_data = build_transaction_data(&to, &data, &value)?; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .user_agent(format!("tempo-wallet/{}", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(NetworkError::Reqwest)?; + + let base_url = build_api_base_url(&auth_server_url)?; + + let mut auth_subtotal_cents = amount_cents; + let redeem_resp = loop { + let signer_info = ctx + .keys + .signer_for_identity_address(wallet_address, ctx.network)?; + + let auth_resp = request_credits_auth_message( + &client, + &base_url, + &wallet, + auth_subtotal_cents, + &transaction_data, + ctx.output_format, + ) + .await?; + + if ctx.output_format == OutputFormat::Text { + eprintln!("Signing authorization..."); + } + + let eip712_digest = compute_eip712_signing_hash(&auth_resp.message)?; + let signature = + signer_info.sign_hash_hex(&eip712_digest, "sign EIP-712 credits authorization")?; + let signature_debug = + build_signature_debug_info(&signer_info, &wallet, eip712_digest, &signature); + + match submit_redeem_transaction( + &client, + SubmitRedeemParams { + base_url: &base_url, + wallet: &wallet, + amount_cents, + transaction_data: &transaction_data, + auth_resp: &auth_resp, + signature: &signature, + signature_debug: &signature_debug, + signer_info: &signer_info, + output_format: ctx.output_format, + }, + ) + .await + { + Ok(response) => break response, + Err(NetworkError::HttpStatus { body, .. }) + if auth_subtotal_cents == amount_cents + && body + .as_deref() + .is_some_and(is_max_credits_authorized_mismatch) => + { + auth_subtotal_cents = + amount_cents.saturating_add(COINFLOW_AUTH_SUBTOTAL_RETRY_BUFFER_CENTS); + if ctx.output_format == OutputFormat::Text { + eprintln!( + "Coinflow fee estimate changed between authorization and submit; retrying with refreshed authorization..." + ); + } + } + Err(error) => return Err(error.into()), + } + }; + + let result = SpendCreditsResult { + wallet, + amount_cents, + tx_hash: redeem_resp.hash, + }; + + result.render(ctx.output_format) +} + +async fn request_credits_auth_message( + client: &reqwest::Client, + base_url: &str, + wallet: &str, + auth_subtotal_cents: u64, + transaction_data: &serde_json::Value, + output_format: OutputFormat, +) -> Result { + if output_format == OutputFormat::Text { + eprintln!("Requesting credits authorization..."); + } + + let auth_msg_url = format!("{base_url}/api/coinflow/redeem/auth-msg"); + let auth_msg_body = serde_json::json!({ + "wallet": wallet, + "subtotal": { + "cents": auth_subtotal_cents, + "currency": "USD" + }, + "transactionData": transaction_data + }); + + let resp = client + .post(&auth_msg_url) + .json(&auth_msg_body) + .send() + .await + .map_err(NetworkError::Reqwest)?; + + let resp_status = resp.status(); + let resp_text = resp.text().await.map_err(NetworkError::Reqwest)?; + + { + let coinflow_sent = serde_json::json!({ + "coinflow_endpoint": "POST https://api-sandbox.coinflow.cash/api/redeem/evm/creditsAuthMsg", + "headers": { + "x-coinflow-auth-wallet": wallet, + "x-coinflow-auth-blockchain": COINFLOW_BLOCKCHAIN, + }, + "body": { + "merchantId": "(from server config)", + "subtotal": { "cents": auth_subtotal_cents, "currency": "USD" }, + "transactionData": transaction_data, + }, + }); + let coinflow_returned: serde_json::Value = serde_json::from_str(&resp_text) + .unwrap_or_else(|_| serde_json::Value::String(resp_text.clone())); + let log = format!( + "=== COINFLOW creditsAuthMsg ===\n\n--- SENT ---\n{}\n\n--- RETURNED (HTTP {}) ---\n{}", + serde_json::to_string_pretty(&coinflow_sent).unwrap_or_default(), + resp_status.as_u16(), + serde_json::to_string_pretty(&coinflow_returned).unwrap_or_default(), + ); + std::fs::write("/tmp/coinflow-request.log", &log).ok(); + } + + if !resp_status.is_success() { + return Err(NetworkError::HttpStatus { + operation: "get credits auth message", + status: resp_status.as_u16(), + body: Some(resp_text), + } + .into()); + } + + serde_json::from_str(&resp_text) + .map_err(|source| NetworkError::ResponseParse { + context: "auth msg", + source, + }) + .map_err(Into::into) +} + +async fn submit_redeem_transaction( + client: &reqwest::Client, + params: SubmitRedeemParams<'_>, +) -> Result { + if params.output_format == OutputFormat::Text { + eprintln!("Submitting redeem transaction..."); + } + + let redeem_url = format!("{}/api/coinflow/redeem/send", params.base_url); + let redeem_body = serde_json::json!({ + "wallet": params.wallet, + "subtotal": { + "cents": params.amount_cents, + "currency": "USD" + }, + "transactionData": params.transaction_data, + "permitCreditsSignature": params.signature, + "validBefore": params.auth_resp.valid_before, + "nonce": params.auth_resp.nonce, + "creditsRawAmount": params.auth_resp.credits_raw_amount + }); + + let resp = client + .post(&redeem_url) + .json(&redeem_body) + .send() + .await + .map_err(NetworkError::Reqwest)?; + + let resp_status = resp.status(); + let resp_text = resp.text().await.map_err(NetworkError::Reqwest)?; + + { + let coinflow_sent = serde_json::json!({ + "coinflow_endpoint": "POST https://api-sandbox.coinflow.cash/api/redeem/evm/sendGaslessTx", + "headers": { + "x-coinflow-auth-wallet": params.wallet, + "x-coinflow-auth-blockchain": COINFLOW_BLOCKCHAIN, + }, + "body": { + "merchantId": "(from server config)", + "subtotal": { "cents": params.amount_cents, "currency": "USD" }, + "transactionData": params.transaction_data, + "signedMessages": { "permitCredits": params.signature }, + "validBefore": ¶ms.auth_resp.valid_before, + "nonce": ¶ms.auth_resp.nonce, + "creditsRawAmount": params.auth_resp.credits_raw_amount, + }, + "context": { + "wallet_address": params.wallet, + "signer_address": format!("{:#x}", params.signer_info.signer.address()), + "signing_mode": format!("{:?}", params.signer_info.signing_mode), + "note": "permitCredits is sent using the same format as viem/tempo signTypedData. Direct signers return a raw 65-byte secp256k1 signature. V2 access keys return a 0x04 keychain envelope whose inner signature is over keccak256(0x04 || eip712_digest || wallet_address).", + "signature_debug": params.signature_debug, + }, + }); + let coinflow_returned: serde_json::Value = serde_json::from_str(&resp_text) + .unwrap_or_else(|_| serde_json::Value::String(resp_text.clone())); + let log = format!( + "=== COINFLOW sendGaslessTx ===\n\n--- SENT ---\n{}\n\n--- RETURNED (HTTP {}) ---\n{}", + serde_json::to_string_pretty(&coinflow_sent).unwrap_or_default(), + resp_status.as_u16(), + serde_json::to_string_pretty(&coinflow_returned).unwrap_or_default(), + ); + std::fs::write("/tmp/coinflow-response.log", &log).ok(); + } + + if !resp_status.is_success() { + return Err(NetworkError::HttpStatus { + operation: "send redeem transaction", + status: resp_status.as_u16(), + body: Some(resp_text), + }); + } + + serde_json::from_str(&resp_text).map_err(|source| NetworkError::ResponseParse { + context: "redeem response", + source, + }) +} + +fn is_max_credits_authorized_mismatch(body: &str) -> bool { + body.contains("exceeds max credits authorized") +} + +fn compute_eip712_signing_hash(message_json: &str) -> Result { + let typed_data: serde_json::Value = + serde_json::from_str(message_json).map_err(|source| NetworkError::ResponseParse { + context: "EIP-712 typed data", + source, + })?; + + let domain = &typed_data["domain"]; + let domain_separator = compute_domain_separator(domain)?; + + let primary_type = typed_data["primaryType"] + .as_str() + .ok_or_else(|| InputError::InvalidHexInput("missing primaryType".to_string()))?; + let types = &typed_data["types"]; + let message = &typed_data["message"]; + let struct_hash = compute_struct_hash(primary_type, types, message)?; + + // EIP-712 digest: keccak256("\x19\x01" || domainSeparator || structHash) + let mut digest_input = Vec::with_capacity(66); + digest_input.extend_from_slice(&[0x19, 0x01]); + digest_input.extend_from_slice(domain_separator.as_slice()); + digest_input.extend_from_slice(struct_hash.as_slice()); + Ok(keccak256(&digest_input)) +} + +fn effective_signing_hash(signer: &Signer, digest: B256) -> (B256, &'static str) { + match &signer.signing_mode { + TempoSigningMode::Direct => (digest, "raw-eip712-digest"), + TempoSigningMode::Keychain { + wallet, version, .. + } => match version { + KeychainVersion::V1 => (digest, "keychain-v1-raw-eip712-digest"), + KeychainVersion::V2 => ( + KeychainSignature::signing_hash(digest, *wallet), + "keychain-v2-wallet-bound-hash", + ), + }, + } +} + +fn build_signature_debug_info( + signer: &Signer, + wallet: &str, + eip712_digest: B256, + signature_hex: &str, +) -> SignatureDebugInfo { + let (effective_hash, effective_hash_kind) = effective_signing_hash(signer, eip712_digest); + let signature_bytes = hex::decode(signature_hex.trim_start_matches("0x")).unwrap_or_default(); + let parsed_signature = TempoSignature::from_bytes(&signature_bytes).ok(); + let parsed_keychain = parsed_signature + .as_ref() + .and_then(TempoSignature::as_keychain); + let keychain_embedded_wallet_address = + parsed_keychain.map(|keychain| format!("{:#x}", keychain.user_address)); + let keychain_inner_signer_from_eip712_digest = parsed_keychain + .and_then(|keychain| keychain.key_id(&eip712_digest).ok()) + .map(|address| format!("{address:#x}")); + let recovered_from_eip712_digest = parsed_signature + .as_ref() + .and_then(|signature| signature.recover_signer(&eip712_digest).ok()) + .map(|address| format!("{address:#x}")); + let recovered_from_effective_signing_hash = parsed_signature + .as_ref() + .and_then(|signature| signature.recover_signer(&effective_hash).ok()) + .map(|address| format!("{address:#x}")); + let expected_signer_address = format!("{:#x}", signer.signer.address()); + let expected_wallet_address = wallet.to_string(); + + SignatureDebugInfo { + eip712_digest: format!("{eip712_digest:#x}"), + effective_signing_hash: format!("{effective_hash:#x}"), + effective_signing_hash_kind: effective_hash_kind, + signature_length_bytes: signature_bytes.len(), + keychain_embedded_wallet_address: keychain_embedded_wallet_address.clone(), + keychain_inner_signer_from_eip712_digest: keychain_inner_signer_from_eip712_digest.clone(), + recovered_from_eip712_digest: recovered_from_eip712_digest.clone(), + recovered_from_effective_signing_hash: recovered_from_effective_signing_hash.clone(), + expected_signer_address: expected_signer_address.clone(), + expected_wallet_address: expected_wallet_address.clone(), + matches_keychain_wallet_address: keychain_embedded_wallet_address + .as_deref() + .is_some_and(|address| address == expected_wallet_address), + matches_keychain_inner_signer_on_eip712_digest: keychain_inner_signer_from_eip712_digest + .as_deref() + .is_some_and(|address| address == expected_signer_address), + matches_signer_on_eip712_digest: recovered_from_eip712_digest + .as_deref() + .is_some_and(|address| address == expected_signer_address), + matches_signer_on_effective_signing_hash: recovered_from_effective_signing_hash + .as_deref() + .is_some_and(|address| address == expected_signer_address), + matches_wallet_on_eip712_digest: recovered_from_eip712_digest + .as_deref() + .is_some_and(|address| address == expected_wallet_address), + matches_wallet_on_effective_signing_hash: recovered_from_effective_signing_hash + .as_deref() + .is_some_and(|address| address == expected_wallet_address), + } +} + +/// Compute the EIP-712 domain separator hash. +fn compute_domain_separator(domain: &serde_json::Value) -> Result { + let mut domain_type_parts = vec![]; + let mut domain_values: Vec> = vec![]; + + if domain.get("name").is_some() { + domain_type_parts.push("string name"); + let name = domain["name"].as_str().unwrap_or(""); + domain_values.push(keccak256(name.as_bytes()).to_vec()); + } + if domain.get("version").is_some() { + domain_type_parts.push("string version"); + let version = domain["version"].as_str().unwrap_or(""); + domain_values.push(keccak256(version.as_bytes()).to_vec()); + } + if domain.get("chainId").is_some() { + domain_type_parts.push("uint256 chainId"); + let chain_id = domain["chainId"] + .as_u64() + .ok_or_else(|| InputError::InvalidHexInput("invalid chainId".to_string()))?; + let mut buf = [0u8; 32]; + buf[24..].copy_from_slice(&chain_id.to_be_bytes()); + domain_values.push(buf.to_vec()); + } + if domain.get("verifyingContract").is_some() { + domain_type_parts.push("address verifyingContract"); + let addr_str = domain["verifyingContract"] + .as_str() + .ok_or_else(|| InputError::InvalidHexInput("invalid verifyingContract".to_string()))?; + let addr: Address = addr_str.parse().map_err(|_| ConfigError::InvalidAddress { + context: "EIP-712 domain verifyingContract", + value: addr_str.to_string(), + })?; + let mut buf = [0u8; 32]; + buf[12..].copy_from_slice(addr.as_slice()); + domain_values.push(buf.to_vec()); + } + if domain.get("salt").is_some() { + domain_type_parts.push("bytes32 salt"); + let salt_str = domain["salt"] + .as_str() + .ok_or_else(|| InputError::InvalidHexInput("invalid salt".to_string()))?; + let salt_hex = salt_str.strip_prefix("0x").unwrap_or(salt_str); + let salt_bytes = hex::decode(salt_hex) + .map_err(|_| InputError::InvalidHexInput("invalid salt hex".to_string()))?; + domain_values.push(salt_bytes); + } + + let domain_type_str = format!("EIP712Domain({})", domain_type_parts.join(",")); + let type_hash = keccak256(domain_type_str.as_bytes()); + + let mut encoded = Vec::new(); + encoded.extend_from_slice(type_hash.as_slice()); + for val in &domain_values { + encoded.extend_from_slice(val); + } + + Ok(keccak256(&encoded)) +} + +/// Compute the struct hash for a given type, following EIP-712 encoding rules. +fn compute_struct_hash( + type_name: &str, + types: &serde_json::Value, + data: &serde_json::Value, +) -> Result { + let type_hash = compute_type_hash(type_name, types)?; + let encoded_data = encode_data(type_name, types, data)?; + + let mut full = Vec::new(); + full.extend_from_slice(type_hash.as_slice()); + full.extend_from_slice(&encoded_data); + + Ok(keccak256(&full)) +} + +fn compute_type_hash(type_name: &str, types: &serde_json::Value) -> Result { + let type_str = encode_type(type_name, types)?; + Ok(keccak256(type_str.as_bytes())) +} + +/// Encode a type string including all referenced sub-types (sorted). +fn encode_type(type_name: &str, types: &serde_json::Value) -> Result { + let fields = types[type_name].as_array().ok_or_else(|| { + InputError::InvalidHexInput(format!("missing type definition for {type_name}")) + })?; + + let mut params = Vec::new(); + let mut referenced_types = std::collections::BTreeSet::new(); + + for field in fields { + let field_type = field["type"] + .as_str() + .ok_or_else(|| InputError::InvalidHexInput("missing field type".to_string()))?; + let field_name = field["name"] + .as_str() + .ok_or_else(|| InputError::InvalidHexInput("missing field name".to_string()))?; + params.push(format!("{field_type} {field_name}")); + + let base_type = field_type.trim_end_matches("[]"); + if types.get(base_type).is_some() && base_type != type_name { + collect_referenced_types(base_type, types, &mut referenced_types); + } + } + + let primary = format!("{type_name}({})", params.join(",")); + let mut result = primary; + for ref_type in &referenced_types { + result.push_str(&encode_type_single(ref_type, types)?); + } + Ok(result) +} + +fn encode_type_single(type_name: &str, types: &serde_json::Value) -> Result { + let fields = types[type_name].as_array().ok_or_else(|| { + InputError::InvalidHexInput(format!("missing type definition for {type_name}")) + })?; + let mut params = Vec::new(); + for field in fields { + let field_type = field["type"].as_str().unwrap_or(""); + let field_name = field["name"].as_str().unwrap_or(""); + params.push(format!("{field_type} {field_name}")); + } + Ok(format!("{type_name}({})", params.join(","))) +} + +fn collect_referenced_types( + type_name: &str, + types: &serde_json::Value, + collected: &mut std::collections::BTreeSet, +) { + if !collected.insert(type_name.to_string()) { + return; + } + if let Some(fields) = types[type_name].as_array() { + for field in fields { + if let Some(field_type) = field["type"].as_str() { + let base_type = field_type.trim_end_matches("[]"); + if types.get(base_type).is_some() && base_type != type_name { + collect_referenced_types(base_type, types, collected); + } + } + } + } +} + +/// Encode the data values according to EIP-712 rules. +fn encode_data( + type_name: &str, + types: &serde_json::Value, + data: &serde_json::Value, +) -> Result, TempoError> { + let fields = types[type_name].as_array().ok_or_else(|| { + InputError::InvalidHexInput(format!("missing type definition for {type_name}")) + })?; + + let mut encoded = Vec::new(); + + for field in fields { + let field_type = field["type"] + .as_str() + .ok_or_else(|| InputError::InvalidHexInput("missing field type".to_string()))?; + let field_name = field["name"] + .as_str() + .ok_or_else(|| InputError::InvalidHexInput("missing field name".to_string()))?; + let value = &data[field_name]; + + let encoded_value = encode_value(field_type, types, value)?; + encoded.extend_from_slice(&encoded_value); + } + + Ok(encoded) +} + +/// Encode a single value according to its EIP-712 type. +fn encode_value( + field_type: &str, + types: &serde_json::Value, + value: &serde_json::Value, +) -> Result, TempoError> { + // Handle array types + if let Some(base_type) = field_type.strip_suffix("[]") { + let items = value + .as_array() + .ok_or_else(|| InputError::InvalidHexInput("expected array value".to_string()))?; + let mut inner = Vec::new(); + for item in items { + inner.extend_from_slice(&encode_value(base_type, types, item)?); + } + return Ok(keccak256(&inner).to_vec()); + } + + // Handle struct types (referenced custom types) + if types.get(field_type).is_some() { + let hash = compute_struct_hash(field_type, types, value)?; + return Ok(hash.to_vec()); + } + + // Handle atomic types + match field_type { + "address" => { + let addr_str = value.as_str().ok_or_else(|| { + InputError::InvalidHexInput("expected address string".to_string()) + })?; + let addr: Address = addr_str.parse().map_err(|_| ConfigError::InvalidAddress { + context: "EIP-712 field", + value: addr_str.to_string(), + })?; + let mut buf = [0u8; 32]; + buf[12..].copy_from_slice(addr.as_slice()); + Ok(buf.to_vec()) + } + "bool" => { + let b = value.as_bool().unwrap_or(false); + let mut buf = [0u8; 32]; + if b { + buf[31] = 1; + } + Ok(buf.to_vec()) + } + "string" => { + let s = value.as_str().unwrap_or(""); + Ok(keccak256(s.as_bytes()).to_vec()) + } + "bytes" => { + let hex_str = value.as_str().unwrap_or("0x"); + let hex_clean = hex_str.strip_prefix("0x").unwrap_or(hex_str); + let bytes = hex::decode(hex_clean) + .map_err(|_| InputError::InvalidHexInput("invalid bytes hex".to_string()))?; + Ok(keccak256(&bytes).to_vec()) + } + t if t.starts_with("bytes") => { + // bytesN (fixed-size) + let hex_str = value.as_str().unwrap_or("0x"); + let hex_clean = hex_str.strip_prefix("0x").unwrap_or(hex_str); + let bytes = hex::decode(hex_clean) + .map_err(|_| InputError::InvalidHexInput("invalid bytesN hex".to_string()))?; + let mut buf = [0u8; 32]; + let len = bytes.len().min(32); + buf[..len].copy_from_slice(&bytes[..len]); + Ok(buf.to_vec()) + } + t if t.starts_with("uint") || t.starts_with("int") => { + let mut buf = [0u8; 32]; + if let Some(n) = value.as_u64() { + buf[24..].copy_from_slice(&n.to_be_bytes()); + } else if let Some(s) = value.as_str() { + if let Some(hex_val) = s.strip_prefix("0x") { + let bytes = hex::decode(hex_val) + .map_err(|_| InputError::InvalidHexInput("invalid uint hex".to_string()))?; + let start = 32 - bytes.len().min(32); + buf[start..start + bytes.len().min(32)] + .copy_from_slice(&bytes[..bytes.len().min(32)]); + } else if let Ok(n) = s.parse::() { + buf[16..].copy_from_slice(&n.to_be_bytes()); + } else { + let n: u64 = s.parse().map_err(|_| { + InputError::InvalidHexInput(format!("invalid numeric value: {s}")) + })?; + buf[24..].copy_from_slice(&n.to_be_bytes()); + } + } else if let Some(n) = value.as_i64() { + buf[24..].copy_from_slice(&(n as u64).to_be_bytes()); + } + Ok(buf.to_vec()) + } + _ => { + let hex_str = value.as_str().unwrap_or("0x"); + let hex_clean = hex_str.strip_prefix("0x").unwrap_or(hex_str); + let bytes = hex::decode(hex_clean).unwrap_or_default(); + let mut buf = [0u8; 32]; + let len = bytes.len().min(32); + buf[..len].copy_from_slice(&bytes[..len]); + Ok(buf.to_vec()) + } + } +} + +fn build_api_base_url(auth_server_url: &str) -> Result { + let url = url::Url::parse(auth_server_url).map_err(|source| InputError::UrlParseFor { + context: "auth server", + source, + })?; + Ok(url.origin().ascii_serialization()) +} + +fn build_transaction_data( + to: &str, + data: &str, + value: &str, +) -> Result { + if !is_zero_value(value)? { + return Err(ConfigError::Invalid( + "Coinflow credits redeem does not support non-zero ETH value".to_string(), + ) + .into()); + } + + if data == "0x" { + return Ok(serde_json::json!({ + "type": "token", + "destination": to, + })); + } + + Ok(serde_json::json!({ + "transaction": { + "to": to, + "data": data, + }, + })) +} + +fn is_zero_value(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Ok(true); + } + + if let Some(hex_value) = value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + { + if !hex_value.chars().all(|ch| ch.is_ascii_hexdigit()) { + return Err(InputError::InvalidHexInput(format!("invalid ETH value: {value}")).into()); + } + return Ok(hex_value.is_empty() || hex_value.bytes().all(|byte| byte == b'0')); + } + + if !value.chars().all(|ch| ch.is_ascii_digit()) { + return Err(InputError::InvalidHexInput(format!("invalid ETH value: {value}")).into()); + } + + Ok(value.bytes().all(|byte| byte == b'0')) +} + +impl SpendCreditsResult { + fn render(&self, format: OutputFormat) -> Result<(), TempoError> { + output::emit_by_format(format, self, || { + let w = &mut std::io::stdout(); + writeln!(w, "{:>10}: {}", "Wallet", self.wallet)?; + writeln!( + w, + "{:>10}: ${:.2}", + "Amount", + self.amount_cents as f64 / 100.0 + )?; + writeln!(w, "{:>10}: {}", "TX Hash", self.tx_hash)?; + Ok(()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use alloy::primitives::Address; + use tempo_common::{keys::Keystore, network::NetworkId}; + use zeroize::Zeroizing; + + const TEST_PRIVATE_KEY: &str = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + const TEST_ADDRESS: &str = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + const COINFLOW_AUTH_MSG: &str = r#"{"domain":{"name":"Coinflow Credits Contract","version":"1","chainId":42431,"verifyingContract":"0x02af2603e2A7d891684854CBC4aaeBa310bf7C1c"},"message":{"customerWallet":"0x480F8659821A7a5f6209cDA338A53E9Dea09DB46","creditSeed":"tempo-sandbox","amount":1030000,"validBefore":"1777483839","nonce":"0x7968399a1307417362f545e43d5a12eb942562dd8c181d41f68fd881f56ba23d"},"primaryType":"CreditsAuthorization","types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"CreditsAuthorization":[{"name":"customerWallet","type":"address"},{"name":"creditSeed","type":"string"},{"name":"amount","type":"uint256"},{"name":"validBefore","type":"uint256"},{"name":"nonce","type":"bytes32"}]}}"#; + + #[test] + fn compute_coinflow_auth_message_matches_reference_hash() { + let digest = compute_eip712_signing_hash(COINFLOW_AUTH_MSG).unwrap(); + + assert_eq!( + format!("{digest:#x}"), + "0x3caf17f85e96e489a081eab08cdc14794d8725d4356ef252fc98de3c65e03225" + ); + } + + #[test] + fn sign_coinflow_auth_message_with_access_key_matches_viem_keychain_envelope() { + let mut keys = Keystore::default(); + let wallet: Address = "0x480f8659821a7a5f6209cda338a53e9dea09db46" + .parse() + .unwrap(); + let entry = keys.upsert_by_wallet_address_and_chain(wallet, 4217); + entry.key_address = Some(TEST_ADDRESS.to_string()); + entry.key = Some(Zeroizing::new(TEST_PRIVATE_KEY.to_string())); + let signer = keys.signer(NetworkId::Tempo).unwrap(); + let digest = compute_eip712_signing_hash(COINFLOW_AUTH_MSG).unwrap(); + let signature = signer + .sign_hash_hex(&digest, "sign EIP-712 credits authorization") + .unwrap(); + let signature_bytes = hex::decode(signature.trim_start_matches("0x")).unwrap(); + let parsed = TempoSignature::from_bytes(&signature_bytes).unwrap(); + let keychain = parsed.as_keychain().expect("expected keychain envelope"); + + assert_eq!( + signature, + "0x04480f8659821a7a5f6209cda338a53e9dea09db46c940b8c39d08d4a737ed58543b2cd922debb9881fb6efc05ac4fa3269b0c28c13e04a2abe52b1aad61b0d5e1b79fe85fcef6f093878d237a7be2e12b1cb7c6c01b" + ); + assert_eq!(signature.len(), 174, "0x + 86 byte keychain envelope"); + assert!(signature.starts_with("0x04")); + assert_eq!(parsed.recover_signer(&digest).unwrap(), wallet); + assert_eq!(keychain.key_id(&digest).unwrap(), signer.signer.address()); + } + + #[test] + fn build_transaction_data_uses_token_redeem_shape_without_calldata() { + let transaction_data = build_transaction_data(TEST_ADDRESS, "0x", "0").unwrap(); + + assert_eq!( + transaction_data, + serde_json::json!({ + "type": "token", + "destination": TEST_ADDRESS, + }) + ); + } + + #[test] + fn build_transaction_data_uses_normal_redeem_shape_with_calldata() { + let transaction_data = build_transaction_data(TEST_ADDRESS, "0xdeadbeef", "0").unwrap(); + + assert_eq!( + transaction_data, + serde_json::json!({ + "transaction": { + "to": TEST_ADDRESS, + "data": "0xdeadbeef", + }, + }) + ); + } + + #[test] + fn build_transaction_data_rejects_non_zero_eth_value() { + let err = build_transaction_data(TEST_ADDRESS, "0xdeadbeef", "1").unwrap_err(); + + assert!(err + .to_string() + .contains("Coinflow credits redeem does not support non-zero ETH value")); + } + + #[test] + fn detects_max_credits_authorized_mismatch() { + assert!(is_max_credits_authorized_mismatch( + r#"{"error":"Failed to send redeem transaction","detail":"HTTP 412: {\"message\":\"Error Processing your request\",\"details\":\"Total 1.04 exceeds max credits authorized 1.03\"}"}"# + )); + } + + #[test] + fn ignores_other_coinflow_failures() { + assert!(!is_max_credits_authorized_mismatch( + r#"{"error":"Failed to send redeem transaction","detail":"HTTP 412: {\"message\":\"Error Processing your request\",\"details\":\"Wallet does not have enough credits to complete redeem request\"}"}"# + )); + } +} diff --git a/crates/tempo-wallet/tests/remote_flows.rs b/crates/tempo-wallet/tests/remote_flows.rs index a88a5994..db9cdee0 100644 --- a/crates/tempo-wallet/tests/remote_flows.rs +++ b/crates/tempo-wallet/tests/remote_flows.rs @@ -5,7 +5,11 @@ use std::{ sync::{Arc, Mutex}, }; -use axum::{routing::post, Json, Router}; +use alloy::{sol, sol_types::SolCall}; +use axum::{ + routing::{get, post}, + Json, Router, +}; use serde_json::json; use tempo_test::{mock_rpc_response, TestConfigBuilder, MODERATO_DIRECT_KEYS_TOML}; @@ -14,6 +18,14 @@ use common::test_command; const AUTHORIZED_WALLET_ADDRESS: &str = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"; const MODERATO_TOKEN_ADDRESS: &str = "0x20c0000000000000000000000000000000000000"; const BALANCE_OF_SELECTOR: &str = "70a08231"; +const CREDITS_CONTRACT_ADDRESS: &str = "0xbF720eF3c2BC8AA59a282782da26b56918eB3D7a"; +const CREDIT_SEED: &str = "tempo-test-credit-seed"; + +sol! { + interface ITempoCredits { + function getCreditsBalance(address customerWallet_, string creditSeed_) external view returns (uint256); + } +} struct MockLoginServer { base_url: String, @@ -102,12 +114,21 @@ struct BalanceSequenceRpcServer { base_url: String, balances: Arc>>, last_value: Arc>, + credit_balances: Arc>>, + last_credit_value: Arc>, shutdown_tx: Option>, _handle: tokio::task::JoinHandle<()>, } impl BalanceSequenceRpcServer { async fn start(raw_balances: Vec<&str>) -> Self { + Self::start_with_credit_balances(raw_balances, Vec::new()).await + } + + async fn start_with_credit_balances( + raw_balances: Vec<&str>, + raw_credit_balances: Vec<&str>, + ) -> Self { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let base_url = format!("http://{}:{}", addr.ip(), addr.port()); @@ -119,36 +140,83 @@ impl BalanceSequenceRpcServer { .collect(), )); let last_value = Arc::new(Mutex::new(String::from("0"))); + let credit_balances = Arc::new(Mutex::new( + raw_credit_balances + .into_iter() + .map(std::string::ToString::to_string) + .collect(), + )); + let last_credit_value = Arc::new(Mutex::new(String::from("0"))); let shared_balances = balances.clone(); let shared_last_value = last_value.clone(); + let rpc_credit_balances = credit_balances.clone(); + let rpc_last_credit_value = last_credit_value.clone(); + let http_credit_balances = credit_balances.clone(); + let http_last_credit_value = last_credit_value.clone(); - let app = Router::new().route( - "/", - post(move |Json(body): Json| { - let shared_balances = shared_balances.clone(); - let shared_last_value = shared_last_value.clone(); - async move { - if let Some(batch) = body.as_array() { - let response = serde_json::Value::Array( - batch - .iter() - .map(|req| { - handle_rpc_request(req, &shared_balances, &shared_last_value) - }) - .collect(), - ); - Json(response) - } else { - Json(handle_rpc_request( - &body, - &shared_balances, - &shared_last_value, - )) + let app = Router::new() + .route( + "/", + post(move |Json(body): Json| { + let shared_balances = shared_balances.clone(); + let shared_last_value = shared_last_value.clone(); + let shared_credit_balances = rpc_credit_balances.clone(); + let shared_last_credit_value = rpc_last_credit_value.clone(); + async move { + if let Some(batch) = body.as_array() { + let response = serde_json::Value::Array( + batch + .iter() + .map(|req| { + handle_rpc_request( + req, + &shared_balances, + &shared_last_value, + &shared_credit_balances, + &shared_last_credit_value, + ) + }) + .collect(), + ); + Json(response) + } else { + Json(handle_rpc_request( + &body, + &shared_balances, + &shared_last_value, + &shared_credit_balances, + &shared_last_credit_value, + )) + } } - } - }), - ); + }), + ) + .route( + "/api/coinflow/balances", + get(move || { + let shared_credit_balances = http_credit_balances.clone(); + let shared_last_credit_value = http_last_credit_value.clone(); + async move { + let raw = next_balance(&shared_credit_balances, &shared_last_credit_value); + Json(json!({ + "credits": { + "rawAmount": raw, + } + })) + } + }), + ) + .route( + "/api/coinflow/config", + get(|| async { + Json(json!({ + "merchantId": "merchant-test", + "creditSeed": CREDIT_SEED, + "env": "sandbox", + })) + }), + ); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); let handle = tokio::spawn(async move { @@ -164,10 +232,16 @@ impl BalanceSequenceRpcServer { base_url, balances, last_value, + credit_balances, + last_credit_value, shutdown_tx: Some(shutdown_tx), _handle: handle, } } + + fn auth_url(&self) -> String { + format!("{}/cli-auth", self.base_url) + } } impl Drop for BalanceSequenceRpcServer { @@ -182,6 +256,8 @@ fn handle_rpc_request( req: &serde_json::Value, balances: &Arc>>, last_value: &Arc>, + credit_balances: &Arc>>, + last_credit_value: &Arc>, ) -> serde_json::Value { if is_fund_balance_request(req) { let raw = next_balance(balances, last_value); @@ -193,6 +269,16 @@ fn handle_rpc_request( }); } + if is_credit_balance_request(req) { + let raw = next_balance(credit_balances, last_credit_value); + let encoded = encode_raw_balance(&raw); + return json!({ + "jsonrpc": "2.0", + "id": req["id"].clone(), + "result": encoded, + }); + } + mock_rpc_response(req, 42431) } @@ -212,12 +298,39 @@ fn assert_remote_login_handoff(stderr: &str) { assert!(stderr.contains("one more authorization link"), "{stderr}"); } -fn assert_remote_fund_handoff(stderr: &str) { - assert!(stderr.contains("Fund URL:"), "{stderr}"); - assert!(stderr.contains("Open this link on your device"), "{stderr}"); +fn assert_remote_fund_handoff(stderr: &str, expected_url: &str) { + assert!( + stderr.contains(&format!("Fund URL: {expected_url}")), + "{stderr}" + ); + assert!( + stderr.contains(&format!("Open this link on your device: {expected_url}")), + "{stderr}" + ); assert!(stderr.contains("After funding is complete"), "{stderr}"); } +fn assert_remote_credits_handoff(stderr: &str, expected_url: &str) { + assert!( + stderr.contains(&format!("Fund URL: {expected_url}")), + "{stderr}" + ); + assert!( + stderr.contains(&format!("Open this link on your device: {expected_url}")), + "{stderr}" + ); + assert!( + stderr.contains("Complete the credits purchase in the wallet app."), + "{stderr}" + ); + assert!( + stderr.contains("After purchasing credits, return here to continue."), + "{stderr}" + ); + assert!(stderr.contains("Waiting for credits..."), "{stderr}"); + assert!(!stderr.contains("Waiting for funding..."), "{stderr}"); +} + fn is_fund_balance_request(req: &serde_json::Value) -> bool { if req["method"].as_str() != Some("eth_call") { return false; @@ -244,6 +357,35 @@ fn is_fund_balance_request(req: &serde_json::Value) -> bool { && data.eq_ignore_ascii_case(&balance_of_call_data(AUTHORIZED_WALLET_ADDRESS)) } +fn is_credit_balance_request(req: &serde_json::Value) -> bool { + if req["method"].as_str() != Some("eth_call") { + return false; + } + + let Some(params) = req["params"].as_array() else { + return false; + }; + let Some(call) = params.first().and_then(serde_json::Value::as_object) else { + return false; + }; + let Some(to) = call.get("to").and_then(serde_json::Value::as_str) else { + return false; + }; + let Some(data) = call + .get("data") + .or_else(|| call.get("input")) + .and_then(serde_json::Value::as_str) + else { + return false; + }; + + normalized_hex(to) == normalized_hex(CREDITS_CONTRACT_ADDRESS) + && data.eq_ignore_ascii_case(&credits_balance_call_data( + AUTHORIZED_WALLET_ADDRESS, + CREDIT_SEED, + )) +} + fn next_balance( balances: &Arc>>, last_value: &Arc>, @@ -276,6 +418,16 @@ fn balance_of_call_data(account: &str) -> String { format!("0x{BALANCE_OF_SELECTOR}{:0>64}", normalized_hex(account)) } +fn credits_balance_call_data(account: &str, credit_seed: &str) -> String { + let call = ITempoCredits::getCreditsBalanceCall { + customerWallet_: account.parse().unwrap(), + creditSeed_: credit_seed.to_string(), + } + .abi_encode(); + + format!("0x{}", hex::encode(call)) +} + fn moderato_config_toml(rpc_url: &str) -> String { format!("[rpc]\n\"tempo-moderato\" = \"{rpc_url}\"\n") } @@ -300,6 +452,8 @@ fn unrelated_eth_call_uses_default_rpc_response_and_does_not_advance_balance_seq String::from("1000000"), ]))); let last_value = Arc::new(Mutex::new(String::from("0"))); + let credit_balances = Arc::new(Mutex::new(VecDeque::new())); + let last_credit_value = Arc::new(Mutex::new(String::from("0"))); let request = json!({ "jsonrpc": "2.0", "id": 7, @@ -313,7 +467,13 @@ fn unrelated_eth_call_uses_default_rpc_response_and_does_not_advance_balance_seq ] }); - let response = handle_rpc_request(&request, &balances, &last_value); + let response = handle_rpc_request( + &request, + &balances, + &last_value, + &credit_balances, + &last_credit_value, + ); assert_eq!(response, mock_rpc_response(&request, 42431)); assert_eq!( @@ -330,6 +490,8 @@ fn matching_balance_request_advances_sequence_and_repeats_last_value() { String::from("1000000"), ]))); let last_value = Arc::new(Mutex::new(String::from("0"))); + let credit_balances = Arc::new(Mutex::new(VecDeque::new())); + let last_credit_value = Arc::new(Mutex::new(String::from("0"))); let request = json!({ "jsonrpc": "2.0", "id": 8, @@ -343,9 +505,27 @@ fn matching_balance_request_advances_sequence_and_repeats_last_value() { ] }); - let first = handle_rpc_request(&request, &balances, &last_value); - let second = handle_rpc_request(&request, &balances, &last_value); - let third = handle_rpc_request(&request, &balances, &last_value); + let first = handle_rpc_request( + &request, + &balances, + &last_value, + &credit_balances, + &last_credit_value, + ); + let second = handle_rpc_request( + &request, + &balances, + &last_value, + &credit_balances, + &last_credit_value, + ); + let third = handle_rpc_request( + &request, + &balances, + &last_value, + &credit_balances, + &last_credit_value, + ); assert_eq!(first["result"], encode_raw_balance("0")); assert_eq!(second["result"], encode_raw_balance("1000000")); @@ -460,7 +640,7 @@ async fn fund_no_browser_prints_remote_safe_handoff_copy_and_detects_balance_cha assert!(output.status.success(), "fund should succeed: {output:?}"); let stderr = String::from_utf8_lossy(&output.stderr); - assert_remote_fund_handoff(&stderr); + assert_remote_fund_handoff(&stderr, "https://wallet.moderato.tempo.xyz/?action=fund"); assert!(rpc.balances.lock().unwrap().is_empty()); assert_eq!(rpc.last_value.lock().unwrap().as_str(), "1000000"); } @@ -481,7 +661,7 @@ async fn fund_no_browser_json_prints_remote_handoff() { assert!(output.status.success(), "fund should succeed: {output:?}"); let stderr = String::from_utf8_lossy(&output.stderr); - assert_remote_fund_handoff(&stderr); + assert_remote_fund_handoff(&stderr, "https://wallet.moderato.tempo.xyz/?action=fund"); assert!(rpc.balances.lock().unwrap().is_empty()); assert_eq!(rpc.last_value.lock().unwrap().as_str(), "1000000"); } @@ -502,11 +682,134 @@ async fn fund_no_browser_toon_prints_remote_handoff() { assert!(output.status.success(), "fund should succeed: {output:?}"); let stderr = String::from_utf8_lossy(&output.stderr); - assert_remote_fund_handoff(&stderr); + assert_remote_fund_handoff(&stderr, "https://wallet.moderato.tempo.xyz/?action=fund"); + assert!(rpc.balances.lock().unwrap().is_empty()); + assert_eq!(rpc.last_value.lock().unwrap().as_str(), "1000000"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fund_no_browser_crypto_uses_direct_crypto_link() { + let rpc = BalanceSequenceRpcServer::start(vec!["0", "1000000"]).await; + let temp = build_fund_temp(&rpc.base_url); + + let output = test_command(&temp) + .env( + "TEMPO_AUTH_URL", + "https://wallet.moderato.tempo.xyz/cli-auth", + ) + .args(["-n", "tempo-moderato", "fund", "--crypto", "--no-browser"]) + .output() + .unwrap(); + + assert!(output.status.success(), "fund should succeed: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_remote_fund_handoff(&stderr, "https://wallet.moderato.tempo.xyz/?action=crypto"); assert!(rpc.balances.lock().unwrap().is_empty()); assert_eq!(rpc.last_value.lock().unwrap().as_str(), "1000000"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fund_no_browser_referral_code_uses_claim_link() { + let rpc = BalanceSequenceRpcServer::start(vec!["0", "1000000"]).await; + let temp = build_fund_temp(&rpc.base_url); + + let output = test_command(&temp) + .env( + "TEMPO_AUTH_URL", + "https://wallet.moderato.tempo.xyz/cli-auth", + ) + .args([ + "-n", + "tempo-moderato", + "fund", + "--referral-code", + "ABC123", + "--no-browser", + ]) + .output() + .unwrap(); + + assert!(output.status.success(), "fund should succeed: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_remote_fund_handoff(&stderr, "https://wallet.moderato.tempo.xyz/?claim=ABC123"); + assert!(rpc.balances.lock().unwrap().is_empty()); + assert_eq!(rpc.last_value.lock().unwrap().as_str(), "1000000"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fund_no_browser_credits_waits_for_credit_balance_change() { + let rpc = BalanceSequenceRpcServer::start_with_credit_balances( + vec!["0", "1000000"], + vec!["0", "10000"], + ) + .await; + let temp = build_fund_temp(&rpc.base_url); + let expected_url = format!("{}/?action=credits", rpc.base_url); + + let output = test_command(&temp) + .env("TEMPO_AUTH_URL", rpc.auth_url()) + .env("TEMPO_CREDITS_RPC_URL", &rpc.base_url) + .args(["-n", "tempo-moderato", "fund", "--credits", "--no-browser"]) + .output() + .unwrap(); + + assert!(output.status.success(), "fund should succeed: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_remote_credits_handoff(&stderr, &expected_url); + assert!(stderr.contains("Credits received!"), "{stderr}"); + assert!(stderr.contains("Credit balance: 0 -> 1"), "{stderr}"); + assert_eq!( + rpc.balances + .lock() + .unwrap() + .iter() + .cloned() + .collect::>(), + vec![String::from("0"), String::from("1000000")] + ); + assert_eq!(rpc.last_value.lock().unwrap().as_str(), "0"); + assert!(rpc.credit_balances.lock().unwrap().is_empty()); + assert_eq!(rpc.last_credit_value.lock().unwrap().as_str(), "10000"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn credits_shows_current_credit_balance() { + let rpc = BalanceSequenceRpcServer::start_with_credit_balances(vec!["0"], vec!["12345"]).await; + let temp = build_fund_temp(&rpc.base_url); + + let output = test_command(&temp) + .env("TEMPO_AUTH_URL", rpc.auth_url()) + .env("TEMPO_CREDITS_RPC_URL", &rpc.base_url) + .args(["-n", "tempo-moderato", "credits"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "credits should succeed: {output:?}" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.trim().is_empty(), "{stderr}"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Wallet"), "{stdout}"); + assert!(stdout.contains(AUTHORIZED_WALLET_ADDRESS), "{stdout}"); + assert!(stdout.contains("Credits"), "{stdout}"); + assert!(stdout.contains("1.2345"), "{stdout}"); + let remaining_credit_balances = rpc + .credit_balances + .lock() + .unwrap() + .iter() + .cloned() + .collect::>(); + assert!( + remaining_credit_balances.is_empty(), + "{remaining_credit_balances:#?}" + ); + assert_eq!(rpc.last_credit_value.lock().unwrap().as_str(), "12345"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fund_default_flow_keeps_local_copy_and_does_not_print_remote_handoff_text() { let rpc = BalanceSequenceRpcServer::start(vec!["0", "1000000"]).await; @@ -523,7 +826,10 @@ async fn fund_default_flow_keeps_local_copy_and_does_not_print_remote_handoff_te assert!(output.status.success(), "fund should succeed: {output:?}"); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("Fund URL:"), "{stderr}"); + assert!( + stderr.contains("Fund URL: https://wallet.moderato.tempo.xyz/?action=fund"), + "{stderr}" + ); assert!( !stderr.contains("Open this link on your device"), "unexpected remote-safe handoff text: {stderr}" diff --git a/scripts/tempo-request-moderato-local b/scripts/tempo-request-moderato-local new file mode 100755 index 00000000..bf7ed974 --- /dev/null +++ b/scripts/tempo-request-moderato-local @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_PATH="${BASH_SOURCE[0]}" +while [ -L "$SCRIPT_PATH" ]; do + LINK_TARGET="$(readlink "$SCRIPT_PATH")" + if [[ "$LINK_TARGET" = /* ]]; then + SCRIPT_PATH="$LINK_TARGET" + else + SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" + SCRIPT_PATH="$SCRIPT_DIR/$LINK_TARGET" + fi +done + +SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +APP_ORIGIN="${TEMPO_MODERATO_APP_URL:-https://app.moderato.tempo.local:3001}" +APP_ORIGIN="${APP_ORIGIN%/}" + +export TEMPO_HOME="${TEMPO_HOME:-$REPO_ROOT/.local/moderato-wallet}" +export TEMPO_AUTH_URL="${TEMPO_AUTH_URL:-$APP_ORIGIN/cli-auth}" +export TEMPO_NO_TELEMETRY="${TEMPO_NO_TELEMETRY:-1}" + +mkdir -p "$TEMPO_HOME" + +if [ -x "$REPO_ROOT/target/debug/tempo-request" ]; then + exec "$REPO_ROOT/target/debug/tempo-request" -n tempo-moderato "$@" +fi + +exec cargo run --manifest-path "$REPO_ROOT/Cargo.toml" -q -p tempo-request -- -n tempo-moderato "$@" diff --git a/scripts/tempo-wallet-moderato-local b/scripts/tempo-wallet-moderato-local new file mode 100755 index 00000000..5599f2c5 --- /dev/null +++ b/scripts/tempo-wallet-moderato-local @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_PATH="${BASH_SOURCE[0]}" +while [ -L "$SCRIPT_PATH" ]; do + LINK_TARGET="$(readlink "$SCRIPT_PATH")" + if [[ "$LINK_TARGET" = /* ]]; then + SCRIPT_PATH="$LINK_TARGET" + else + SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" + SCRIPT_PATH="$SCRIPT_DIR/$LINK_TARGET" + fi +done + +SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +APP_ORIGIN="${TEMPO_MODERATO_APP_URL:-https://app.moderato.tempo.local:3001}" +APP_ORIGIN="${APP_ORIGIN%/}" + +export TEMPO_HOME="${TEMPO_HOME:-$REPO_ROOT/.local/moderato-wallet}" +export TEMPO_AUTH_URL="${TEMPO_AUTH_URL:-$APP_ORIGIN/cli-auth}" +export TEMPO_NO_TELEMETRY="${TEMPO_NO_TELEMETRY:-1}" + +mkdir -p "$TEMPO_HOME" + +if [ -x "$REPO_ROOT/target/debug/tempo-wallet" ]; then + exec "$REPO_ROOT/target/debug/tempo-wallet" -n tempo-moderato "$@" +fi + +exec cargo run --manifest-path "$REPO_ROOT/Cargo.toml" -q -p tempo-wallet -- -n tempo-moderato "$@"