Skip to content
Open
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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ members = ["lib", "cli"]
resolver = "2"

[workspace.package]
version = "0.1.1"
version = "0.1.2"
edition = "2021"
license = "MIT"
repository = "https://github.com/stripe/purl"
Expand Down
1 change: 1 addition & 0 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ solana-sdk.workspace = true
spl-associated-token-account.workspace = true
futures = "0.3.31"
ctrlc = "3.4"
time = { version = "0.3", features = ["formatting"] }

[dev-dependencies]
assert_cmd = "2.1"
Expand Down
52 changes: 35 additions & 17 deletions cli/src/balance_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,29 @@ use std::io::{IsTerminal, Write};
use std::sync::{Arc, Mutex};
use tokio::time::{interval, Duration};

fn wallet_display(chain: &str, addr: &str, short_addr: &str) -> String {
let linked_addr = wallet_link(addr, chain);

if linked_addr != addr {
if crate::hyperlink::supports_hyperlinks() {
crate::hyperlink::terminal_hyperlink(
&purl_lib::network::get_network(match chain {
"EVM" => "base",
"Solana" => "solana",
_ => "",
})
.and_then(|n| n.address_url(addr))
.unwrap_or_default(),
short_addr,
)
} else {
addr.to_string()
}
} else {
addr.to_string()
}
}

/// Check token balances for configured networks
pub async fn balance_command(
config: &Config,
Expand Down Expand Up @@ -54,23 +77,7 @@ pub async fn balance_command(
if !wallet_info.is_empty() {
println!("{}", "Wallet".green().bold());
for (chain, addr, short_addr) in &wallet_info {
let linked_addr = wallet_link(addr, chain);
// Create display with short address as the visible text but full address in the link
let display = if linked_addr != *addr {
// Has a link - create hyperlink with short display text
crate::hyperlink::hyperlink(
&purl_lib::network::get_network(match *chain {
"EVM" => "base",
"Solana" => "solana",
_ => "",
})
.and_then(|n| n.address_url(addr))
.unwrap_or_default(),
short_addr,
)
} else {
short_addr.clone()
};
let display = wallet_display(chain, addr, short_addr);
println!(" {} {}", display.yellow(), format!("({})", chain).dimmed());
}
println!();
Expand Down Expand Up @@ -354,6 +361,17 @@ mod tests {
assert_eq!(usdc.format_atomic(1_500_000), "1.500000");
}

#[test]
fn wallet_display_returns_full_address_when_hyperlinks_are_disabled() {
let addr = "0x1234567890abcdef1234567890abcdef12345678";
let short = "0x1234...5678";

let display = wallet_display("EVM", addr, short);

assert_eq!(display, addr);
assert!(!display.contains("\x1B]8;;"));
}

#[test]
fn test_by_chain_type() {
let evm_networks = Network::by_chain_type(ChainType::Evm, None);
Expand Down
10 changes: 10 additions & 0 deletions cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ Examples:
purl wallet add # Interactive wallet creation
purl wallet add --type evm # Skip type selection
purl wallet add --type solana -k KEY # Import existing key
purl wallet add --type evm -k KEY --password pass --set-active # Non-interactive
purl wallet list # List all wallets
purl wallet use my-wallet # Switch active wallet
purl wallet verify my-wallet # Check wallet integrity
Expand Down Expand Up @@ -406,6 +407,12 @@ pub enum WalletCommands {
/// Private key to import (hex for EVM, base58 for Solana)
#[arg(short = 'k', long)]
private_key: Option<String>,
/// Password for keystore encryption (skips interactive prompt)
#[arg(short = 'p', long)]
password: Option<String>,
/// Set as active wallet without prompting (use --set-active=false to skip)
#[arg(long)]
set_active: Option<bool>,
},
/// Show wallet details
Show {
Expand All @@ -416,6 +423,9 @@ pub enum WalletCommands {
Verify {
/// Name of the wallet (without .json extension)
name: String,
/// Password for wallet decryption (skips interactive prompt)
#[arg(short = 'p', long)]
password: Option<String>,
},
/// Set a wallet as the active payment method
Use {
Expand Down
69 changes: 62 additions & 7 deletions cli/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//! for how to fix common problems.

use crate::colors::Colors;
use purl_lib::error::CompatibilityReason;
use purl_lib::PurlError;

/// Get a suggestion for how to fix an error, if available.
Expand Down Expand Up @@ -75,12 +76,35 @@ fn get_purl_error_suggestion(err: &PurlError) -> Option<String> {

PurlError::InvalidPassword => Some("Check your keystore password and try again.".into()),

PurlError::NoCompatibleMethod { networks } => {
PurlError::NoCompatibleMethod { networks, reason } => {
let networks_str = networks.join(", ");
Some(format!(
"Server accepts: {networks_str}\n\
Configure a wallet for one of these networks with 'purl wallet add'."
))
match reason {
Some(CompatibilityReason::MissingWallet { required_chains }) => {
let chains = required_chains.join(", ");
Some(format!(
"Server accepts: {networks_str}\n\
Missing configured wallet for: {chains}\n\
Add one with 'purl wallet add' and activate with 'purl wallet use <name>'."
))
}
Some(CompatibilityReason::UnsupportedToken { network, asset }) => Some(format!(
"Server accepts: {networks_str}\n\
Token {asset} is not configured for {network}.\n\
Add it in ~/.purl/config.toml under [[tokens]], or use an endpoint/network with a supported token."
)),
Some(CompatibilityReason::NetworkFiltered { allowed_networks }) => {
let allowed = allowed_networks.join(", ");
Some(format!(
"Server accepts: {networks_str}\n\
Your --network filter excludes all accepted networks (allowed: {allowed}).\n\
Remove or adjust --network."
))
}
None => Some(format!(
"Server accepts: {networks_str}\n\
No compatible method was found. Check wallet configuration, network filters, and token support."
)),
}
}

PurlError::AmountExceedsMax { required, max } => Some(format!(
Expand Down Expand Up @@ -235,9 +259,9 @@ fn get_related_commands(err: &anyhow::Error) -> Option<Vec<&'static str>> {
"purl config # View current configuration",
]),
PurlError::NoCompatibleMethod { .. } => Some(vec![
"purl networks list # See supported networks",
"purl wallet new # Create a new wallet",
"purl inspect <url> # Check payment requirements",
"purl wallet list # Show configured wallets",
"purl wallet add # Add a wallet",
]),
PurlError::AmountExceedsMax { .. } => Some(vec![
"purl inspect <url> # Check payment requirements",
Expand Down Expand Up @@ -306,4 +330,35 @@ mod tests {
assert!(suggestion.is_some());
assert!(suggestion.unwrap().contains("purl networks list"));
}

#[test]
fn test_no_compatible_method_missing_wallet_suggestion() {
let err = PurlError::NoCompatibleMethod {
networks: vec!["solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp".into()],
reason: Some(CompatibilityReason::MissingWallet {
required_chains: vec!["solana".into()],
}),
};
let suggestion = get_purl_error_suggestion(&err);
assert!(suggestion.is_some());
let suggestion = suggestion.unwrap();
assert!(suggestion.contains("Missing configured wallet"));
assert!(suggestion.contains("purl wallet add"));
}

#[test]
fn test_no_compatible_method_unsupported_token_suggestion() {
let err = PurlError::NoCompatibleMethod {
networks: vec!["solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp".into()],
reason: Some(CompatibilityReason::UnsupportedToken {
network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp".into(),
asset: "11111111111111111111111111111111".into(),
}),
};
let suggestion = get_purl_error_suggestion(&err);
assert!(suggestion.is_some());
let suggestion = suggestion.unwrap();
assert!(suggestion.contains("not configured"));
assert!(suggestion.contains("[[tokens]]"));
}
}
108 changes: 91 additions & 17 deletions cli/src/hyperlink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
//! Modern terminals support clickable hyperlinks via the OSC 8 standard.
//! Terminals that don't support it will simply show the visible text.

use colored::control::SHOULD_COLORIZE;
use std::io::IsTerminal;

/// Format a clickable hyperlink for terminals that support OSC 8.
///
/// The format is: `\x1B]8;;URL\x07TEXT\x1B]8;;\x07`
Expand All @@ -13,13 +16,33 @@ pub fn hyperlink(url: &str, text: &str) -> String {
format!("\x1B]8;;{}\x07{}\x1B]8;;\x07", url, text)
}

/// Format a clickable hyperlink only when terminal decoration is appropriate.
///
/// Hyperlinks are suppressed for non-TTY output and when color/terminal decoration
/// has been disabled via the existing color control path.
pub fn supports_hyperlinks() -> bool {
std::io::stdout().is_terminal() && SHOULD_COLORIZE.should_colorize()
}

/// Format a clickable hyperlink only when terminal decoration is appropriate.
///
/// Hyperlinks are suppressed for non-TTY output and when color/terminal decoration
/// has been disabled via the existing color control path.
pub fn terminal_hyperlink(url: &str, text: &str) -> String {
if supports_hyperlinks() {
hyperlink(url, text)
} else {
text.to_string()
}
}

/// Format a transaction hash as a hyperlink if network supports it.
///
/// Returns plain text if the network is unknown or has no explorer configured.
pub fn tx_link(tx_hash: &str, network: &str) -> String {
if let Some(info) = purl_lib::network::get_network(network) {
if let Some(url) = info.tx_url(tx_hash) {
return hyperlink(&url, tx_hash);
return terminal_hyperlink(&url, tx_hash);
}
}
tx_hash.to_string()
Expand All @@ -31,7 +54,7 @@ pub fn tx_link(tx_hash: &str, network: &str) -> String {
pub fn address_link(address: &str, network: &str) -> String {
if let Some(info) = purl_lib::network::get_network(network) {
if let Some(url) = info.address_url(address) {
return hyperlink(&url, address);
return terminal_hyperlink(&url, address);
}
}
address.to_string()
Expand Down Expand Up @@ -63,17 +86,44 @@ mod tests {
}

#[test]
fn test_tx_link_known_network() {
fn test_terminal_hyperlink_plain_text_when_not_tty() {
let link = terminal_hyperlink("https://example.com", "click me");
assert_eq!(link, "click me");
}

#[test]
fn test_supports_hyperlinks_false_when_not_tty() {
assert!(!supports_hyperlinks());
}

#[test]
fn test_tx_link_known_network_plain_text_when_not_tty() {
let link = tx_link("0x123abc", "base");
assert!(link.contains("basescan.org"));
assert!(link.contains("/tx/0x123abc"));
assert_eq!(link, "0x123abc");
}

#[test]
fn test_address_link_known_network() {
fn test_tx_url_lookup_known_network() {
let url = purl_lib::network::get_network("base")
.and_then(|n| n.tx_url("0x123abc"))
.expect("base tx url");
assert!(url.contains("basescan.org"));
assert!(url.contains("/tx/0x123abc"));
}

#[test]
fn test_address_link_known_network_plain_text_when_not_tty() {
let link = address_link("0xabcdef", "ethereum");
assert!(link.contains("etherscan.io"));
assert!(link.contains("/address/0xabcdef"));
assert_eq!(link, "0xabcdef");
}

#[test]
fn test_address_url_lookup_known_network() {
let url = purl_lib::network::get_network("ethereum")
.and_then(|n| n.address_url("0xabcdef"))
.expect("ethereum address url");
assert!(url.contains("etherscan.io"));
assert!(url.contains("/address/0xabcdef"));
}

#[test]
Expand All @@ -83,24 +133,48 @@ mod tests {
}

#[test]
fn test_solana_address_link() {
fn test_solana_address_link_plain_text_when_not_tty() {
let link = address_link("5xyzABC", "solana");
assert!(link.contains("solscan.io"));
assert!(link.contains("/account/5xyzABC"));
assert_eq!(link, "5xyzABC");
}

#[test]
fn test_solana_address_url_lookup() {
let url = purl_lib::network::get_network("solana")
.and_then(|n| n.address_url("5xyzABC"))
.expect("solana address url");
assert!(url.contains("solscan.io"));
assert!(url.contains("/account/5xyzABC"));
}

#[test]
fn test_wallet_link_evm() {
fn test_wallet_link_evm_plain_text_when_not_tty() {
let link = wallet_link("0xabcdef123456", "EVM");
assert!(link.contains("basescan.org"));
assert!(link.contains("/address/0xabcdef123456"));
assert_eq!(link, "0xabcdef123456");
}

#[test]
fn test_wallet_link_evm_uses_base_url_lookup() {
let url = purl_lib::network::get_network("base")
.and_then(|n| n.address_url("0xabcdef123456"))
.expect("base address url");
assert!(url.contains("basescan.org"));
assert!(url.contains("/address/0xabcdef123456"));
}

#[test]
fn test_wallet_link_solana() {
fn test_wallet_link_solana_plain_text_when_not_tty() {
let link = wallet_link("5xyzABC", "Solana");
assert!(link.contains("solscan.io"));
assert!(link.contains("/account/5xyzABC"));
assert_eq!(link, "5xyzABC");
}

#[test]
fn test_wallet_link_solana_uses_solana_url_lookup() {
let url = purl_lib::network::get_network("solana")
.and_then(|n| n.address_url("5xyzABC"))
.expect("solana wallet url");
assert!(url.contains("solscan.io"));
assert!(url.contains("/account/5xyzABC"));
}

#[test]
Expand Down
Loading