use serde_json::{Value, json};
use polyc_payments_client::resolver::DelegatedKeyResolution;
const WALLET_NEEDS_RELINK_CAUSE: &str = "spending access expired";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WalletState {
NotLinked,
Ready,
NeedsRelink,
Unavailable,
}
impl WalletState {
#[must_use]
pub const fn token(self) -> &'static str {
match self {
Self::NotLinked => "not_linked",
Self::Ready => "ready",
Self::NeedsRelink => "needs_relink",
Self::Unavailable => "temporarily_unavailable",
}
}
}
#[must_use]
pub const fn wallet_state(has_link: bool, resolution: &DelegatedKeyResolution) -> WalletState {
match (has_link, resolution) {
(false, _) => WalletState::NotLinked,
(true, DelegatedKeyResolution::Usable(_)) => WalletState::Ready,
(true, DelegatedKeyResolution::LinkedButUnusable) => WalletState::NeedsRelink,
(
true,
DelegatedKeyResolution::TemporarilyUnavailable(_) | DelegatedKeyResolution::Unlinked,
) => WalletState::Unavailable,
}
}
pub const PERIOD_ONCE_SECS: u64 = 0;
pub const PERIOD_DAY_SECS: u64 = 86_400;
pub const PERIOD_WEEK_SECS: u64 = 604_800;
pub const PERIOD_MONTH_SECS: u64 = 2_592_000;
#[must_use]
pub fn period_secs_from_str(period: &str) -> Option<u64> {
match period {
"once" => Some(PERIOD_ONCE_SECS),
"day" => Some(PERIOD_DAY_SECS),
"week" => Some(PERIOD_WEEK_SECS),
"month" => Some(PERIOD_MONTH_SECS),
_ => None,
}
}
#[derive(Debug, Clone, Default)]
pub struct SpendPolicyView {
pub limit_human: String,
pub period_secs: u64,
pub max_lifetime_secs: u64,
pub allowed_hosts: Vec<String>,
}
#[must_use]
pub fn policy_summary(policy: &SpendPolicyView) -> Option<String> {
let mut sentences = Vec::new();
if !policy.limit_human.is_empty() {
sentences.push(format!(
"This wallet won't pay more than {} {} in a single payment{}.",
policy.limit_human,
crate::amount::SETTLEMENT_SYMBOL,
hosts_clause(&policy.allowed_hosts),
));
} else if !policy.allowed_hosts.is_empty() {
sentences.push(format!(
"This wallet can only pay {}.",
policy.allowed_hosts.join(", ")
));
}
if policy.max_lifetime_secs > 0 {
sentences.push(format!(
"Its link lapses after {}.",
lifetime_phrase(policy.max_lifetime_secs)
));
}
if sentences.is_empty() {
None
} else {
Some(sentences.join(" "))
}
}
fn hosts_clause(allowed_hosts: &[String]) -> String {
if allowed_hosts.is_empty() {
String::new()
} else {
format!(", to {} only", allowed_hosts.join(", "))
}
}
fn lifetime_phrase(max_lifetime_secs: u64) -> String {
let days = (max_lifetime_secs / PERIOD_DAY_SECS).max(1);
if days == 1 {
"1 day".to_owned()
} else {
format!("{days} days")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemainingSpendSource {
Onchain,
LocalEstimate,
}
pub struct RemainingSpendView {
pub base_units: u128,
pub source: RemainingSpendSource,
}
pub struct WalletView {
pub address: String,
pub currency: String,
pub balance_base_units: Option<u128>,
pub remaining: Option<RemainingSpendView>,
}
#[must_use]
pub fn render_status(
state: WalletState,
wallet: Option<&WalletView>,
policy: Option<&SpendPolicyView>,
decimals: u32,
chain_id: u64,
explorer_base: &str,
expiry_note: Option<&str>,
) -> Value {
let summary = status_summary(state, wallet, expiry_note);
let mut out = json!({
"linked": state != WalletState::NotLinked,
"status": state.token(),
"summary": summary,
});
if let Some(policy_line) = policy.and_then(policy_summary) {
out["policy_summary"] = json!(policy_line);
}
if let Some(w) = wallet {
let mut wallet_json = json!({
"address": w.address,
"currency": w.currency,
"network": network_name(chain_id),
});
if let Some(url) =
polyc_payments_client::explorer::evm_address_explorer_url(explorer_base, &w.address)
{
wallet_json["explorer"] = json!(url);
}
if let Some(bal) = w.balance_base_units {
wallet_json["balance"] = json!(crate::amount::format_settled_amount(bal, decimals));
} else {
wallet_json["balance_note"] = json!("Balance couldn't be read just now.");
}
if let Some(remaining) = &w.remaining {
let mut remaining_json = json!({
"amount": crate::amount::format_settled_amount(remaining.base_units, decimals),
});
if remaining.source == RemainingSpendSource::LocalEstimate {
remaining_json["note"] = json!(
"This is your configured spending limit, not the exact amount left — the chain doesn't report a remaining allowance for this wallet."
);
}
wallet_json["remaining_allowance"] = remaining_json;
}
out["wallet"] = wallet_json;
}
out
}
fn status_summary(
state: WalletState,
wallet: Option<&WalletView>,
expiry_note: Option<&str>,
) -> String {
let short = wallet.map_or_else(|| "your wallet".to_owned(), |w| shorten(&w.address));
match state {
WalletState::NotLinked => {
"No spending wallet is linked yet. Link one and it can pay for things on your behalf."
.to_owned()
}
WalletState::Ready => format!("Your spending wallet {short} is linked and ready to pay."),
WalletState::NeedsRelink => {
let when = expiry_note.map_or_else(String::new, |d| format!(" on {d}"));
format!("Your {WALLET_NEEDS_RELINK_CAUSE} for {short}{when} — renew it to keep paying.")
}
WalletState::Unavailable => {
"Couldn't check your spending wallet just now. Try again in a moment.".to_owned()
}
}
}
pub struct PaymentView {
pub reference: String,
pub amount_base_units: u128,
pub method: String,
pub timestamp: String,
}
#[must_use]
pub fn render_history(
payments: &[PaymentView],
limit: usize,
decimals: u32,
explorer_base: &str,
) -> Value {
let shown: Vec<Value> = payments
.iter()
.take(limit)
.map(|p| {
let mut entry = json!({
"amount": crate::amount::format_settled_amount(p.amount_base_units, decimals),
"reference": p.reference,
"method": p.method,
"settled_at": p.timestamp,
});
if let Some(url) =
polyc_payments_client::explorer::evm_tx_explorer_url(explorer_base, &p.reference)
{
entry["explorer"] = json!(url);
}
entry
})
.collect();
let note = if shown.is_empty() {
"You haven't made any payments in this conversation yet."
} else {
"Payments you've made in this conversation."
};
json!({
"count": shown.len(),
"payments": shown,
"note": note,
})
}
pub struct RosterEntry {
pub who: String,
pub address: Option<String>,
pub balance_base_units: Option<u128>,
}
#[must_use]
pub fn render_roster(entries: &[RosterEntry], decimals: u32, explorer_base: &str) -> Value {
let wallets: Vec<Value> = entries
.iter()
.map(|e| {
let mut w = json!({ "who": e.who, "linked": e.address.is_some() });
if let Some(addr) = &e.address {
w["address"] = json!(addr);
if let Some(url) =
polyc_payments_client::explorer::evm_address_explorer_url(explorer_base, addr)
{
w["explorer"] = json!(url);
}
if let Some(bal) = e.balance_base_units {
w["balance"] = json!(crate::amount::format_settled_amount(bal, decimals));
}
}
w
})
.collect();
json!({
"count": wallets.len(),
"wallets": wallets,
"note": "Everyone who's taken part in this conversation, and whether they've linked a spending wallet.",
})
}
#[must_use]
pub fn render_link(url: &str) -> String {
json!({
"link_url": url,
"summary": "Here's a secure link to set up your spending wallet. Open it on your device to finish — it only takes a few seconds.",
})
.to_string()
}
#[must_use]
pub fn shorten(address: &str) -> String {
let chars: Vec<char> = address.chars().collect();
if chars.len() <= 12 {
return address.to_owned();
}
let head: String = chars[..6].iter().collect();
let tail: String = chars[chars.len() - 4..].iter().collect();
format!("{head}…{tail}")
}
fn network_name(chain_id: u64) -> String {
match chain_id {
crate::MODERATO_CHAIN_ID => "Tempo Moderato testnet".to_owned(),
other => format!("Tempo (chain {other})"),
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery)]
use super::*;
use polyc_payments_client::resolver::DelegatedKeyRef;
fn usable() -> DelegatedKeyResolution {
DelegatedKeyResolution::Usable(DelegatedKeyRef {
keys_toml: "[[keys]]".to_owned(),
currency: "0x20c0000000000000000000000000000000000000".to_owned(),
})
}
#[test]
fn state_machine_covers_every_case() {
assert_eq!(wallet_state(false, &usable()), WalletState::NotLinked);
assert_eq!(
wallet_state(false, &DelegatedKeyResolution::Unlinked),
WalletState::NotLinked
);
assert_eq!(wallet_state(true, &usable()), WalletState::Ready);
assert_eq!(
wallet_state(true, &DelegatedKeyResolution::LinkedButUnusable),
WalletState::NeedsRelink
);
assert_eq!(
wallet_state(
true,
&DelegatedKeyResolution::TemporarilyUnavailable("x".into())
),
WalletState::Unavailable
);
assert_eq!(
wallet_state(true, &DelegatedKeyResolution::Unlinked),
WalletState::Unavailable
);
}
fn wv(balance: Option<u128>) -> WalletView {
WalletView {
address: "0xAA187760A4178Bd4e027F253e2Aae8B2261995be".to_owned(),
currency: "0x20c0000000000000000000000000000000000000".to_owned(),
balance_base_units: balance,
remaining: None,
}
}
fn wv_with_remaining(remaining: RemainingSpendView) -> WalletView {
WalletView {
remaining: Some(remaining),
..wv(Some(0))
}
}
#[test]
fn ready_status_shows_address_balance_explorer_and_network() {
let v = render_status(
WalletState::Ready,
Some(&wv(Some(12_500_000))),
None,
6,
42431,
"https://explore.testnet.tempo.xyz",
None,
);
assert_eq!(v["linked"], true);
assert_eq!(v["status"], "ready");
assert_eq!(
v["wallet"]["address"],
"0xAA187760A4178Bd4e027F253e2Aae8B2261995be"
);
assert_eq!(v["wallet"]["balance"], "12.5 USD");
assert_eq!(v["wallet"]["network"], "Tempo Moderato testnet");
assert!(
v["wallet"]["explorer"]
.as_str()
.unwrap()
.contains("/address/0xaa18"),
"{v}"
);
assert!(v["summary"].as_str().unwrap().contains("ready to pay"));
assert!(
v["summary"].as_str().unwrap().contains("0xAA18…95be"),
"{v}"
);
}
#[test]
fn not_linked_status_omits_the_wallet_block() {
let v = render_status(WalletState::NotLinked, None, None, 6, 42431, "", None);
assert_eq!(v["linked"], false);
assert_eq!(v["status"], "not_linked");
assert!(v.get("wallet").is_none(), "no wallet block when not linked");
assert!(
v["summary"]
.as_str()
.unwrap()
.contains("No spending wallet is linked")
);
}
#[test]
fn unreadable_balance_still_renders_the_wallet_with_a_note() {
let v = render_status(
WalletState::Ready,
Some(&wv(None)),
None,
6,
42431,
"",
None,
);
assert!(v["wallet"]["balance"].is_null());
assert_eq!(
v["wallet"]["balance_note"],
"Balance couldn't be read just now."
);
}
#[test]
fn needs_relink_summary_names_the_expired_cause_and_asks_to_renew() {
let v = render_status(
WalletState::NeedsRelink,
Some(&wv(Some(0))),
None,
6,
42431,
"",
None,
);
assert_eq!(v["status"], "needs_relink");
let summary = v["summary"].as_str().unwrap();
assert!(
summary.contains("spending access expired"),
"must name the cause, not a bare 'needs relink': {summary}"
);
assert!(
summary.contains("renew"),
"must ask for a renewal, the same verb the chat card uses: {summary}"
);
assert!(
!summary.to_lowercase().contains("is linked"),
"must never call an unusable wallet \"linked\": {summary}"
);
}
#[test]
fn needs_relink_summary_carries_the_expiry_date_when_known() {
let v = render_status(
WalletState::NeedsRelink,
Some(&wv(Some(0))),
None,
6,
42431,
"",
Some("Aug 3, 2026"),
);
let summary = v["summary"].as_str().unwrap();
assert!(summary.contains("Aug 3, 2026"), "{summary}");
assert!(summary.contains("spending access expired"), "{summary}");
}
#[test]
fn ready_status_shows_the_onchain_remaining_allowance_with_no_note() {
let v = render_status(
WalletState::Ready,
Some(&wv_with_remaining(RemainingSpendView {
base_units: 4_000_000,
source: RemainingSpendSource::Onchain,
})),
None,
6,
42431,
"",
None,
);
assert_eq!(v["wallet"]["remaining_allowance"]["amount"], "4 USD");
assert!(
v["wallet"]["remaining_allowance"].get("note").is_none(),
"the chain's own answer needs no estimate caveat: {v}"
);
}
#[test]
fn ready_status_flags_a_local_estimate_with_a_configured_limit_note() {
let v = render_status(
WalletState::Ready,
Some(&wv_with_remaining(RemainingSpendView {
base_units: 1_500_000,
source: RemainingSpendSource::LocalEstimate,
})),
None,
6,
42431,
"",
None,
);
assert_eq!(v["wallet"]["remaining_allowance"]["amount"], "1.5 USD");
let note = v["wallet"]["remaining_allowance"]["note"]
.as_str()
.expect("local-estimate source carries a note");
assert!(
note.contains("configured spending limit"),
"note must name this as the configured limit, not a live read: {note}"
);
assert!(
!note.contains("just now") && !note.contains("estimate"),
"note must not imply a transient failure or a spend-adjusted estimate: {note}"
);
}
#[test]
fn ready_status_omits_remaining_allowance_when_it_could_not_be_read() {
let v = render_status(
WalletState::Ready,
Some(&wv(Some(0))),
None,
6,
42431,
"",
None,
);
assert!(
v["wallet"].get("remaining_allowance").is_none(),
"no fabricated figure when the read failed or wasn't attempted: {v}"
);
}
#[test]
fn unknown_chain_renders_generically() {
let v = render_status(
WalletState::Ready,
Some(&wv(Some(0))),
None,
6,
99,
"",
None,
);
assert_eq!(v["wallet"]["network"], "Tempo (chain 99)");
}
#[test]
fn render_status_includes_the_policy_line() {
let policy = SpendPolicyView {
limit_human: "5".to_owned(),
period_secs: PERIOD_DAY_SECS,
max_lifetime_secs: PERIOD_DAY_SECS * 7,
allowed_hosts: vec!["api.example.com".to_owned()],
};
let v = render_status(
WalletState::Ready,
Some(&wv(Some(0))),
Some(&policy),
6,
42431,
"",
None,
);
let line = v["policy_summary"]
.as_str()
.expect("policy_summary present");
assert!(
line.contains("won't pay more than 5 USD in a single payment"),
"{line}"
);
assert!(line.contains("api.example.com only"), "{line}");
assert!(line.contains("lapses after 7 days"), "{line}");
assert!(
!line.contains("per day") && !line.contains("PERIOD"),
"the per-payment ceiling must never be worded as a window \
the policy's `period_secs` doesn't actually enforce: {line}"
);
}
#[test]
fn render_status_omits_the_policy_line_when_none_is_set() {
let v = render_status(
WalletState::Ready,
Some(&wv(Some(0))),
None,
6,
42431,
"",
None,
);
assert!(v.get("policy_summary").is_none(), "{v}");
}
#[test]
fn render_status_omits_the_policy_line_when_the_policy_is_all_cleared() {
let v = render_status(
WalletState::Ready,
Some(&wv(Some(0))),
Some(&SpendPolicyView::default()),
6,
42431,
"",
None,
);
assert!(v.get("policy_summary").is_none(), "{v}");
}
#[test]
fn policy_summary_covers_limit_hosts_and_lifetime_combinations() {
assert!(policy_summary(&SpendPolicyView::default()).is_none());
let limit_only = SpendPolicyView {
limit_human: "5".to_owned(),
..Default::default()
};
let s = policy_summary(&limit_only).expect("some");
assert_eq!(
s,
"This wallet won't pay more than 5 USD in a single payment."
);
let hosts_only = SpendPolicyView {
allowed_hosts: vec!["api.example.com".to_owned()],
..Default::default()
};
let s = policy_summary(&hosts_only).expect("some");
assert_eq!(s, "This wallet can only pay api.example.com.");
let lifetime_only = SpendPolicyView {
max_lifetime_secs: PERIOD_DAY_SECS,
..Default::default()
};
let s = policy_summary(&lifetime_only).expect("some");
assert_eq!(s, "Its link lapses after 1 day.");
}
#[test]
fn history_renders_newest_first_capped_with_explorer_links() {
let hash_a = "0xabc0000000000000000000000000000000000000000000000000000000000001";
let hash_b = "0xdef0000000000000000000000000000000000000000000000000000000000002";
let payments = vec![
PaymentView {
reference: hash_a.to_owned(),
amount_base_units: 100_000,
method: "tempo".to_owned(),
timestamp: "2026-07-04T18:59:55Z".to_owned(),
},
PaymentView {
reference: hash_b.to_owned(),
amount_base_units: 250_000,
method: "tempo".to_owned(),
timestamp: "2026-07-04T12:17:00Z".to_owned(),
},
];
let v = render_history(&payments, 1, 6, "https://explore.testnet.tempo.xyz");
assert_eq!(v["count"], 1);
assert_eq!(v["payments"][0]["amount"], "0.1 USD");
assert_eq!(v["payments"][0]["reference"], hash_a);
assert!(
v["payments"][0]["explorer"]
.as_str()
.unwrap()
.contains(hash_a)
);
assert_eq!(v["note"], "Payments you've made in this conversation.");
}
#[test]
fn empty_history_says_so_and_is_not_an_error() {
let v = render_history(&[], 10, 6, "");
assert_eq!(v["count"], 0);
assert!(v["payments"].as_array().unwrap().is_empty());
assert!(
v["note"]
.as_str()
.unwrap()
.contains("haven't made any payments")
);
}
#[test]
fn shorten_handles_short_and_long() {
assert_eq!(shorten("0x1234"), "0x1234");
assert_eq!(
shorten("0xAA187760A4178Bd4e027F253e2Aae8B2261995be"),
"0xAA18…95be"
);
}
#[test]
fn roster_renders_linked_and_unlinked_participants() {
let entries = vec![
RosterEntry {
who: "Christopher".to_owned(),
address: Some("0xAA187760A4178Bd4e027F253e2Aae8B2261995be".to_owned()),
balance_base_units: Some(5_000_000),
},
RosterEntry {
who: "Erica".to_owned(),
address: None,
balance_base_units: None,
},
];
let v = render_roster(&entries, 6, "https://explore.testnet.tempo.xyz");
assert_eq!(v["count"], 2);
assert_eq!(v["wallets"][0]["who"], "Christopher");
assert_eq!(v["wallets"][0]["linked"], true);
assert_eq!(v["wallets"][0]["balance"], "5 USD");
assert!(
v["wallets"][0]["explorer"]
.as_str()
.unwrap()
.contains("/address/0xaa18"),
"{v}"
);
assert_eq!(v["wallets"][1]["who"], "Erica");
assert_eq!(v["wallets"][1]["linked"], false);
assert!(v["wallets"][1].get("address").is_none(), "{v}");
}
#[test]
fn render_link_carries_url_and_next_step() {
let out = render_link("https://polychrome.sh/wallet/link?token=abc");
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["link_url"], "https://polychrome.sh/wallet/link?token=abc");
assert!(
v["summary"]
.as_str()
.unwrap()
.contains("set up your spending wallet"),
"{v}"
);
}
}