Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changelog/coinflow-credits.md
Original file line number Diff line number Diff line change
@@ -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.
128 changes: 126 additions & 2 deletions crates/tempo-common/src/keys/signer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@
//! 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},
Expand Down Expand Up @@ -84,6 +89,74 @@ impl Signer {
pub fn has_stored_key_authorization(&self) -> bool {
self.stored_key_authorization.is_some()
}

/// 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<Vec<u8>, TempoError> {
let hash_to_sign = match &self.signing_mode {
TempoSigningMode::Direct => *hash,
TempoSigningMode::Keychain {
wallet, version, ..
} => match version {
KeychainVersion::V1 => *hash,
KeychainVersion::V2 => KeychainSignature::signing_hash(*hash, *wallet),
},
};

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<String, TempoError> {
let bytes = self.sign_hash_bytes(hash, operation)?;
Ok(format!("0x{}", hex::encode(bytes)))
}
}

impl Keystore {
Expand Down Expand Up @@ -158,6 +231,7 @@ impl Keystore {
#[cfg(test)]
mod tests {
use super::*;
use alloy::primitives::keccak256;
use zeroize::Zeroizing;

use crate::keys::KeyEntry;
Expand Down Expand Up @@ -300,6 +374,56 @@ 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_signer_no_key_for_network() {
let keys = Keystore::default();
Expand Down
2 changes: 2 additions & 0 deletions crates/tempo-wallet/src/analytics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
22 changes: 19 additions & 3 deletions crates/tempo-wallet/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 } => {
Expand Down Expand Up @@ -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 {
Expand Down
52 changes: 48 additions & 4 deletions crates/tempo-wallet/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<String>,
},
/// Show the current credits balance
#[command(display_order = 8, name = "credits")]
Credits {
/// Wallet address to inspect (defaults to current wallet)
#[arg(long)]
address: Option<String>,
},
/// 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<String>,
},
/// 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<SessionCommands>,
},
/// Browse the MPP service directory
#[command(display_order = 9, name = "services")]
#[command(display_order = 11, name = "services")]
Services {
#[command(subcommand)]
command: Option<ServicesCommands>,
Expand All @@ -105,7 +149,7 @@ Examples:
},

/// Collect debug info for support
#[command(display_order = 10)]
#[command(display_order = 12)]
Debug,

/// Generate shell completions script
Expand Down
43 changes: 43 additions & 0 deletions crates/tempo-wallet/src/commands/credits.rs
Original file line number Diff line number Diff line change
@@ -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<String>) -> 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_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(())
})
}
}
Loading
Loading