use crate::{Chain, Error, Protocol, Source};
use alloy_primitives::{Address, U256};
use rust_decimal::Decimal;
use std::str::FromStr;
pub fn scaled(raw: U256, decimals: u8) -> Result<Decimal, Error> {
if decimals > 28 {
return Err(Error::Integrity {
message: format!("token has {decimals} decimals (>28 is unsupported)"),
});
}
let pow = U256::from(10u64).pow(U256::from(decimals));
let int_part = raw / pow;
let frac_part = raw % pow;
let int_dec = Decimal::from_str(&int_part.to_string()).map_err(|_| Error::Integrity {
message: "amount exceeds Decimal range".into(),
})?;
let frac_i128: i128 = frac_part
.to_string()
.parse()
.map_err(|_| Error::Integrity {
message: "amount fractional overflow".into(),
})?;
let frac_dec = Decimal::try_from_i128_with_scale(frac_i128, decimals as u32).map_err(|_| {
Error::Integrity {
message: "amount scale overflow".into(),
}
})?;
int_dec
.checked_add(frac_dec)
.ok_or_else(|| Error::Integrity {
message: "amount overflow".into(),
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[must_use]
pub struct Complete<T>(T);
impl<T> Complete<T> {
pub fn new(value: T) -> Self {
Complete(value)
}
pub fn get(&self) -> &T {
&self.0
}
pub fn into_inner(self) -> T {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Timestamp(pub i64);
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Staleness {
Live,
Stale,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Provenance {
pub source: Source,
pub chain: Chain,
pub block: Option<u64>,
pub at: Timestamp,
pub staleness: Staleness,
}
impl Provenance {
pub fn new(source: Source, chain: Chain, at: Timestamp, staleness: Staleness) -> Self {
Provenance {
source,
chain,
block: None,
at,
staleness,
}
}
#[must_use]
pub fn with_block(mut self, block: Option<u64>) -> Self {
self.block = block;
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Currency {
Usd,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Money {
pub amount: Decimal,
pub currency: Currency,
}
impl Money {
pub fn new(amount: Decimal, currency: Currency) -> Self {
Money { amount, currency }
}
pub fn usd(amount: Decimal) -> Self {
Money {
amount,
currency: Currency::Usd,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum TokenAddress {
Evm(Address),
Solana(String),
}
impl TokenAddress {
pub fn as_evm(&self) -> Option<Address> {
match self {
TokenAddress::Evm(a) => Some(*a),
_ => None,
}
}
}
impl std::fmt::Display for TokenAddress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TokenAddress::Evm(a) => write!(f, "{a:#x}"),
TokenAddress::Solana(m) => f.write_str(m),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Token {
pub symbol: String,
pub name: Option<String>,
pub address: Option<TokenAddress>,
pub decimals: u8,
}
impl Token {
pub fn new(symbol: impl Into<String>, address: Option<TokenAddress>, decimals: u8) -> Self {
Token {
symbol: symbol.into(),
name: None,
address,
decimals,
}
}
pub fn evm(symbol: impl Into<String>, address: Option<Address>, decimals: u8) -> Self {
Token::new(symbol, address.map(TokenAddress::Evm), decimals)
}
pub fn solana(symbol: impl Into<String>, mint: Option<String>, decimals: u8) -> Self {
Token::new(symbol, mint.map(TokenAddress::Solana), decimals)
}
#[must_use]
pub fn with_name(mut self, name: Option<String>) -> Self {
self.name = name;
self
}
}
pub fn to_raw(amount: Decimal, decimals: u8) -> Result<U256, Error> {
if amount.is_sign_negative() {
return Err(Error::Integrity {
message: "negative amount has no base-unit representation".into(),
});
}
let mantissa = u128::try_from(amount.mantissa()).map_err(|_| Error::Integrity {
message: "amount mantissa out of range".into(),
})?;
let base = U256::from(mantissa);
let scale = amount.scale();
let dec = decimals as u32;
if dec >= scale {
Ok(base * U256::from(10u64).pow(U256::from(dec - scale)))
} else {
Ok(base / U256::from(10u64).pow(U256::from(scale - dec)))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Amount {
pub token: Token,
pub raw: U256,
pub amount: Decimal,
pub usd: Option<Money>,
}
impl Amount {
pub fn from_raw(token: Token, raw: U256) -> Result<Self, Error> {
let amount = scaled(raw, token.decimals)?;
Ok(Amount {
token,
raw,
amount,
usd: None,
})
}
pub fn from_decimal(token: Token, amount: Decimal) -> Result<Self, Error> {
let raw = to_raw(amount, token.decimals)?;
Ok(Amount {
token,
raw,
amount,
usd: None,
})
}
#[must_use]
pub fn with_usd(mut self, usd: Option<Money>) -> Self {
self.usd = usd;
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WalletBalance {
pub amount: Amount,
pub possible_spam: Option<bool>,
pub verified_contract: Option<bool>,
}
impl WalletBalance {
pub fn new(amount: Amount) -> Self {
WalletBalance {
amount,
possible_spam: None,
verified_contract: None,
}
}
#[must_use]
pub fn with_possible_spam(mut self, possible_spam: Option<bool>) -> Self {
self.possible_spam = possible_spam;
self
}
#[must_use]
pub fn with_verified_contract(mut self, verified_contract: Option<bool>) -> Self {
self.verified_contract = verified_contract;
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SuppliedAsset {
pub amount: Amount,
pub liquidation_threshold: Option<Decimal>,
pub max_ltv: Option<Decimal>,
pub is_collateral: bool,
pub can_be_collateral: bool,
pub apy: Option<Decimal>,
}
impl SuppliedAsset {
pub fn new(amount: Amount) -> Self {
SuppliedAsset {
amount,
liquidation_threshold: None,
max_ltv: None,
is_collateral: false,
can_be_collateral: false,
apy: None,
}
}
#[must_use]
pub fn with_liquidation_threshold(mut self, lt: Option<Decimal>) -> Self {
self.liquidation_threshold = lt;
self
}
#[must_use]
pub fn with_max_ltv(mut self, max_ltv: Option<Decimal>) -> Self {
self.max_ltv = max_ltv;
self
}
#[must_use]
pub fn with_collateral(mut self, is_collateral: bool, can_be_collateral: bool) -> Self {
self.is_collateral = is_collateral;
self.can_be_collateral = can_be_collateral;
self
}
#[must_use]
pub fn with_apy(mut self, apy: Option<Decimal>) -> Self {
self.apy = apy;
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BorrowedAsset {
pub amount: Amount,
pub borrow_factor: Option<Decimal>,
pub apy: Option<Decimal>,
}
impl BorrowedAsset {
pub fn new(amount: Amount) -> Self {
BorrowedAsset {
amount,
borrow_factor: None,
apy: None,
}
}
#[must_use]
pub fn with_borrow_factor(mut self, borrow_factor: Option<Decimal>) -> Self {
self.borrow_factor = borrow_factor;
self
}
#[must_use]
pub fn with_apy(mut self, apy: Option<Decimal>) -> Self {
self.apy = apy;
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LendingPosition {
pub supplied: Vec<SuppliedAsset>,
pub borrowed: Vec<BorrowedAsset>,
pub health_factor: Option<Decimal>,
}
impl LendingPosition {
pub fn new(supplied: Vec<SuppliedAsset>, borrowed: Vec<BorrowedAsset>) -> Self {
LendingPosition {
supplied,
borrowed,
health_factor: None,
}
}
#[must_use]
pub fn with_health_factor(mut self, health_factor: Option<Decimal>) -> Self {
self.health_factor = health_factor;
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LiquidityPosition {
pub id: Option<String>,
pub pool: Option<String>,
pub token0: Token,
pub token1: Token,
pub fee_tier_bps: Option<u32>,
pub sqrt_price: Option<String>,
pub tick_spacing: Option<i32>,
pub created_at: Option<i64>,
pub tick_lower: i32,
pub tick_upper: i32,
pub tick_current: i32,
pub in_range: bool,
pub assets: Vec<Amount>,
pub uncollected_fees: Vec<Amount>,
pub deposited: Vec<Amount>,
pub withdrawn: Vec<Amount>,
pub collected_fees: Vec<Amount>,
pub apr: Option<Decimal>,
pub status: PositionStatus,
}
impl LiquidityPosition {
pub fn new(
token0: Token,
token1: Token,
tick_lower: i32,
tick_upper: i32,
tick_current: i32,
in_range: bool,
) -> Self {
LiquidityPosition {
id: None,
pool: None,
token0,
token1,
fee_tier_bps: None,
sqrt_price: None,
tick_spacing: None,
created_at: None,
tick_lower,
tick_upper,
tick_current,
in_range,
assets: Vec::new(),
uncollected_fees: Vec::new(),
deposited: Vec::new(),
withdrawn: Vec::new(),
collected_fees: Vec::new(),
apr: None,
status: PositionStatus::Active,
}
}
#[must_use]
pub fn with_id(mut self, id: Option<String>) -> Self {
self.id = id;
self
}
#[must_use]
pub fn with_pool(mut self, pool: Option<String>) -> Self {
self.pool = pool;
self
}
#[must_use]
pub fn with_fee_tier_bps(mut self, fee_tier_bps: Option<u32>) -> Self {
self.fee_tier_bps = fee_tier_bps;
self
}
#[must_use]
pub fn with_sqrt_price(mut self, sqrt_price: Option<String>) -> Self {
self.sqrt_price = sqrt_price;
self
}
#[must_use]
pub fn with_tick_spacing(mut self, tick_spacing: Option<i32>) -> Self {
self.tick_spacing = tick_spacing;
self
}
#[must_use]
pub fn with_created_at(mut self, created_at: Option<i64>) -> Self {
self.created_at = created_at;
self
}
#[must_use]
pub fn with_assets(mut self, assets: Vec<Amount>) -> Self {
self.assets = assets;
self
}
#[must_use]
pub fn with_uncollected_fees(mut self, uncollected_fees: Vec<Amount>) -> Self {
self.uncollected_fees = uncollected_fees;
self
}
#[must_use]
pub fn with_deposited(mut self, deposited: Vec<Amount>) -> Self {
self.deposited = deposited;
self
}
#[must_use]
pub fn with_withdrawn(mut self, withdrawn: Vec<Amount>) -> Self {
self.withdrawn = withdrawn;
self
}
#[must_use]
pub fn with_collected_fees(mut self, collected_fees: Vec<Amount>) -> Self {
self.collected_fees = collected_fees;
self
}
#[must_use]
pub fn with_apr(mut self, apr: Option<Decimal>) -> Self {
self.apr = apr;
self
}
#[must_use]
pub fn with_status(mut self, status: PositionStatus) -> Self {
self.status = status;
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum PositionStatus {
#[default]
Active,
Inactive,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum YieldKind {
PrincipalToken,
YieldToken,
LiquidityToken,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct YieldPosition {
pub amount: Amount,
pub kind: YieldKind,
pub expiry: Option<Timestamp>,
pub apy: Option<Decimal>,
}
impl YieldPosition {
pub fn new(amount: Amount, kind: YieldKind) -> Self {
YieldPosition {
amount,
kind,
expiry: None,
apy: None,
}
}
#[must_use]
pub fn with_expiry(mut self, expiry: Option<Timestamp>) -> Self {
self.expiry = expiry;
self
}
#[must_use]
pub fn with_apy(mut self, apy: Option<Decimal>) -> Self {
self.apy = apy;
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NftPosition {
pub collection: String,
pub token_id: String,
pub name: Option<String>,
pub floor_price: Option<Money>,
pub possible_spam: Option<bool>,
}
impl NftPosition {
pub fn new(collection: impl Into<String>, token_id: impl Into<String>) -> Self {
NftPosition {
collection: collection.into(),
token_id: token_id.into(),
name: None,
floor_price: None,
possible_spam: None,
}
}
#[must_use]
pub fn with_name(mut self, name: Option<String>) -> Self {
self.name = name;
self
}
#[must_use]
pub fn with_floor_price(mut self, floor_price: Option<Money>) -> Self {
self.floor_price = floor_price;
self
}
#[must_use]
pub fn with_possible_spam(mut self, possible_spam: Option<bool>) -> Self {
self.possible_spam = possible_spam;
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LockPosition {
pub locked: Vec<Amount>,
pub unlock_at: Option<Timestamp>,
}
impl LockPosition {
pub fn new(locked: Vec<Amount>) -> Self {
LockPosition {
locked,
unlock_at: None,
}
}
#[must_use]
pub fn with_unlock_at(mut self, unlock_at: Option<Timestamp>) -> Self {
self.unlock_at = unlock_at;
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StakePosition {
pub staked: Vec<Amount>,
}
impl StakePosition {
pub fn new(staked: Vec<Amount>) -> Self {
StakePosition { staked }
}
}
#[allow(
clippy::large_enum_variant,
reason = "variants kept inline rather than boxed: this is the public domain type matched everywhere downstream, and the size spread does not justify an API-breaking indirection"
)]
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Position {
Wallet(WalletBalance),
Lending(LendingPosition),
Liquidity(LiquidityPosition),
Lock(LockPosition),
Stake(StakePosition),
Yield(YieldPosition),
Nft(NftPosition),
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reading {
pub protocol: Protocol,
pub chain: Chain,
pub source: Source,
pub positions: Vec<Position>,
pub receipt_tokens: Vec<Address>,
pub provenance: Provenance,
}
impl Reading {
pub fn new(
protocol: Protocol,
chain: Chain,
source: Source,
positions: Vec<Position>,
provenance: Provenance,
) -> Self {
Reading {
protocol,
chain,
source,
positions,
receipt_tokens: Vec::new(),
provenance,
}
}
#[must_use]
pub fn with_receipt_tokens(mut self, receipt_tokens: Vec<Address>) -> Self {
self.receipt_tokens = receipt_tokens;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scaled_and_to_raw_round_trip() {
let raw = U256::from(1_500_000u64);
let dec = scaled(raw, 6).unwrap();
assert_eq!(dec, Decimal::from_str_exact("1.5").unwrap());
assert_eq!(to_raw(dec, 6).unwrap(), raw);
}
#[test]
fn to_raw_drops_sub_base_unit_digits() {
let over = Decimal::from_str_exact("1.2345678").unwrap(); assert_eq!(to_raw(over, 6).unwrap(), U256::from(1_234_567u64));
}
#[test]
fn to_raw_rejects_negative() {
assert!(to_raw(Decimal::from_str_exact("-1").unwrap(), 6).is_err());
}
#[test]
fn from_raw_sets_both_views() {
let t = Token::new("WETH", None, 18);
let a = Amount::from_raw(t, U256::from(2_000_000_000_000_000_000u64)).unwrap();
assert_eq!(a.raw, U256::from(2_000_000_000_000_000_000u64));
assert_eq!(a.amount, Decimal::from_str_exact("2").unwrap());
assert!(a.usd.is_none());
}
}