use std::fmt;
use crate::account::Signature;
use crate::client::{ApiError, Network, Transport, body, info, user_request};
use crate::utils::Address;
use crate::utils::sign::{Field, Map, NonceKey, UserAction, UserSigned, Value};
use serde::Deserialize;
pub const MAX_AGENT_VALIDITY_MS: u64 = 180 * 86_400_000;
pub const LONGEST_AGENT_NAME: usize = 16;
const VALID_UNTIL: &str = " valid_until ";
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AgentName(String);
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("an agent's name is 1 to {LONGEST_AGENT_NAME} characters and not a valid_until form, which `{0}` is not")]
pub struct BadAgentName(pub String);
impl AgentName {
pub fn new(name: &str) -> Result<AgentName, BadAgentName> {
let fits = !name.trim().is_empty() && name.chars().count() <= LONGEST_AGENT_NAME;
match fits && !name.contains("valid_until") {
true => Ok(AgentName(name.to_owned())),
false => Err(BadAgentName(name.to_owned())),
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for AgentName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("an agent's approval ends ahead of now ({now}) and within 180 days, which {until} does not")]
pub struct BadExpiry {
pub until: u64,
pub now: u64,
}
#[must_use]
pub fn split_valid_until(name: &str) -> (&str, Option<u64>) {
match name.rsplit_once(VALID_UNTIL) {
Some((base, ms)) if !ms.is_empty() && ms.bytes().all(|b| b.is_ascii_digit()) => (base, ms.parse().ok()),
_ => (name, None),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApproveAgent {
agent: Address,
name: String,
null: bool,
nonce: u64,
}
impl ApproveAgent {
#[must_use]
pub fn new(agent: Address, name: &str, nonce: u64) -> ApproveAgent {
ApproveAgent { agent, name: name.to_owned(), null: false, nonce }
}
#[must_use]
pub const fn unnamed(agent: Address, nonce: u64) -> ApproveAgent {
ApproveAgent { agent, name: String::new(), null: true, nonce }
}
#[must_use]
pub fn named(agent: Address, name: &AgentName, nonce: u64) -> ApproveAgent {
ApproveAgent::new(agent, name.as_str(), nonce)
}
pub fn expiring(
agent: Address,
name: &AgentName,
until: u64,
now: u64,
nonce: u64,
) -> Result<ApproveAgent, BadExpiry> {
let ahead = until.checked_sub(now).filter(|ahead| *ahead > 0 && *ahead <= MAX_AGENT_VALIDITY_MS);
match ahead {
Some(_) => Ok(ApproveAgent::new(agent, &format!("{name}{VALID_UNTIL}{until}"), nonce)),
None => Err(BadExpiry { until, now }),
}
}
#[must_use]
pub const fn agent(&self) -> Address {
self.agent
}
#[must_use]
pub fn name(&self) -> Option<&str> {
Some(self.name.as_str()).filter(|name| !name.is_empty())
}
}
impl UserAction for ApproveAgent {
fn user_signed(&self) -> UserSigned<'_> {
UserSigned::new("approveAgent", "HyperliquidTransaction:ApproveAgent", NonceKey::Nonce, self.nonce)
.field("agentAddress", Field::Address(self.agent))
.field("agentName", Field::String(&self.name))
}
fn action(&self, network: Network) -> Map {
let action = self.user_signed().action(network);
if !self.null {
return action;
}
action.into_pairs().fold(Map::new(), |map, (key, value)| match key == "agentName" {
true => map.put(key, Value::Nil),
false => map.put(key, value),
})
}
fn wire(&self, network: Network) -> String {
self.action(network).to_json().compact()
}
fn body(&self, signature: &Signature, network: Network) -> String {
body::user_signed(&self.action(network), self.nonce, signature)
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Reused {
#[error("this address is one of the account's agents already")]
Listed,
#[error("this address was an agent before, and what it signed then could be replayed")]
Retired,
#[error("the venue knows this address already, as {0:?}; an agent is a fresh address")]
Known(Role),
}
pub fn fresh_agent(candidate: Address, role: &Role, listed: &[Agent], retired: &[Address]) -> Result<(), Reused> {
if listed.iter().any(|agent| agent.address == candidate) {
return Err(Reused::Listed);
}
if retired.contains(&candidate) {
return Err(Reused::Retired);
}
match role {
Role::Missing => Ok(()),
known => Err(Reused::Known(known.clone())),
}
}
#[must_use]
pub fn extra_agents_request(user: Address) -> String {
user_request("extraAgents", user)
}
pub async fn extra_agents(transport: &impl Transport, user: Address) -> Result<Vec<Agent>, ApiError> {
let agents: Option<Vec<Agent>> = info(transport, extra_agents_request(user)).await?;
Ok(agents.unwrap_or_default())
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Agent {
#[serde(default)]
pub name: String,
pub address: Address,
#[serde(default)]
pub valid_until: Option<u64>,
}
impl Agent {
#[must_use]
pub fn is_live(&self, now: u64) -> bool {
self.valid_until.is_none_or(|until| until > now)
}
}
#[must_use]
pub fn role_request(user: Address) -> String {
user_request("userRole", user)
}
pub async fn role(transport: &impl Transport, user: Address) -> Result<Role, ApiError> {
info(transport, role_request(user)).await
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(from = "RawRole")]
pub enum Role {
User,
Missing,
Vault,
Agent {
user: Address,
},
SubAccount {
master: Address,
},
Other(String),
}
impl Role {
#[must_use]
pub const fn agent_of(&self) -> Option<Address> {
match self {
Role::Agent { user } => Some(*user),
_ => None,
}
}
#[must_use]
pub const fn master(&self) -> Option<Address> {
match self {
Role::SubAccount { master } => Some(*master),
_ => None,
}
}
}
#[derive(Deserialize)]
struct RawRole {
#[serde(default)]
role: String,
#[serde(default)]
data: Option<RawRoleData>,
}
#[derive(Deserialize)]
struct RawRoleData {
#[serde(default)]
user: Option<String>,
#[serde(default)]
master: Option<String>,
}
impl From<RawRole> for Role {
fn from(raw: RawRole) -> Role {
let named = |pick: fn(&RawRoleData) -> Option<&String>| -> Option<Address> {
raw.data.as_ref().and_then(pick).and_then(|text| text.parse().ok())
};
match raw.role.as_str() {
"user" => Role::User,
"missing" => Role::Missing,
"vault" => Role::Vault,
"agent" => {
named(|data| data.user.as_ref()).map_or(Role::Other(raw.role.clone()), |user| Role::Agent { user })
}
"subAccount" => named(|data| data.master.as_ref())
.map_or(Role::Other(raw.role.clone()), |master| Role::SubAccount { master }),
_ => Role::Other(raw.role.clone()),
}
}
}
#[cfg(test)]
#[path = "tests/agent.rs"]
mod tests;