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
197 changes: 197 additions & 0 deletions crates/ledger-core/src/crypto.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use ufo_types::{
iso::{Currency, Lei},
satisfies::{Constraint, Satisfies, SatisfiesResult},
};

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Chain {
Bitcoin,
Ethereum,
Solana,
Cardano,
Polkadot,
Avalanche,
Polygon,
Arbitrum,
Optimism,
Bsc,
Other(String),
}

impl Chain {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"bitcoin" | "btc" => Some(Self::Bitcoin),
"ethereum" | "eth" => Some(Self::Ethereum),
"solana" | "sol" => Some(Self::Solana),
"cardano" | "ada" => Some(Self::Cardano),
"polkadot" | "dot" => Some(Self::Polkadot),
"avalanche" | "avax" => Some(Self::Avalanche),
"polygon" | "matic" => Some(Self::Polygon),
"arbitrum" | "arb" => Some(Self::Arbitrum),
"optimism" | "op" => Some(Self::Optimism),
"bsc" | "bnb" => Some(Self::Bsc),
other => Some(Self::Other(other.to_string())),
}
}

pub fn as_str(&self) -> &str {
match self {
Self::Bitcoin => "Bitcoin",
Self::Ethereum => "Ethereum",
Self::Solana => "Solana",
Self::Cardano => "Cardano",
Self::Polkadot => "Polkadot",
Self::Avalanche => "Avalanche",
Self::Polygon => "Polygon",
Self::Arbitrum => "Arbitrum",
Self::Optimism => "Optimism",
Self::Bsc => "BSC",
Self::Other(s) => s.as_str(),
}
}
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CostBasisMethod {
Fifo,
Lifo,
Hifo,
Acb,
SpecificIdentification { lot_refs: Vec<String> },
}

impl CostBasisMethod {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"fifo" => Some(Self::Fifo),
"lifo" => Some(Self::Lifo),
"hifo" => Some(Self::Hifo),
"acb" => Some(Self::Acb),
"specific_id" | "specific_identification" => {
Some(Self::SpecificIdentification { lot_refs: vec![] })
}
_ => None,
}
}

pub fn as_str(&self) -> &str {
match self {
Self::Fifo => "FIFO",
Self::Lifo => "LIFO",
Self::Hifo => "HIFO",
Self::Acb => "ACB",
Self::SpecificIdentification { .. } => "SpecificIdentification",
}
}
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum TaxJurisdiction {
Us,
Au,
}

impl TaxJurisdiction {
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"us" => Self::Us,
_ => Self::Au,
}
}

pub fn as_str(&self) -> &str {
match self {
Self::Us => "US",
Self::Au => "AU",
}
}
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum TxType {
Buy,
Sell,
Staking,
Airdrop,
Spend,
Transfer,
}

impl TxType {
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"buy" => Self::Buy,
"staking" => Self::Staking,
"airdrop" => Self::Airdrop,
"spend" => Self::Spend,
"transfer" => Self::Transfer,
_ => Self::Sell,
}
}
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CryptoWallet {
pub lei: Lei,
pub address: String,
pub chain: Chain,
pub jurisdiction: TaxJurisdiction,
pub cost_basis_method: CostBasisMethod,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CryptoTx {
pub tx_hash: String,
pub wallet: CryptoWallet,
pub tx_type: TxType,
pub gross_proceeds: Decimal,
pub cost_basis: Decimal,
pub date: chrono::NaiveDate,
pub acquisition_date: Option<chrono::NaiveDate>,
pub currency: Currency,
}

impl CryptoTx {
pub fn gain_loss(&self) -> Decimal {
self.gross_proceeds - self.cost_basis
}

pub fn au_discount_eligible(&self) -> bool {
match self.acquisition_date {
Some(acq) => (self.date - acq).num_days() > 365,
None => false,
}
}

pub fn au_taxable_gain(&self) -> Decimal {
if self.au_discount_eligible() {
self.gain_loss() * Decimal::new(5, 1)
} else {
self.gain_loss()
}
}
}

pub struct CryptoCostBasisRules;

impl Constraint for CryptoCostBasisRules {}

impl Satisfies<CryptoCostBasisRules> for CryptoTx {
fn satisfies(&self, _rules: &CryptoCostBasisRules) -> SatisfiesResult {
match &self.wallet.cost_basis_method {
CostBasisMethod::SpecificIdentification { lot_refs } => {
if lot_refs.is_empty() {
SatisfiesResult::violated(
"SpecificIdentification requires at least one lot reference",
)
} else {
SatisfiesResult::satisfied(0.95, vec![])
}
}
_ => SatisfiesResult::satisfied(1.0, vec![]),
}
}
}
1 change: 1 addition & 0 deletions crates/ledger-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod attest;
pub mod calendar;
pub mod classify;
pub mod constraints;
pub mod crypto;
pub mod document;
pub mod document_shape;
pub mod filename;
Expand Down
33 changes: 33 additions & 0 deletions crates/ledgerr-mcp/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ pub const PUBLISHED_TOOLS: [ToolContractSpec; 12] = [
"ambiguity_review",
"schedule_summary",
"export_workbook",
"compute_depreciation",
],
},
ToolContractSpec {
Expand Down Expand Up @@ -681,6 +682,38 @@ pub enum TaxArgs {
acquisition_date: Option<String>,
jurisdiction: String,
currency: String,
cost_basis_method: String,
chain: String,
address: String,
},
/// Compute FEIE (Form 2555) exclusion with hard guard that SE tax is not reduced.
ComputeFeie {
tax_year: u16,
foreign_earned_income: String,
days_qualified: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
housing_exclusion: Option<String>,
test: String,
test_start: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
test_end: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
qualifying_days: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
window_start: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
window_end: Option<String>,
},
/// Compute Schedule E 27.5yr straight-line depreciation with mid-month convention.
ComputeDepreciation {
tax_year: u16,
placed_in_service: String,
total_basis: String,
land_value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
improvements: Option<Vec<Vec<String>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
prior_accumulated: Option<String>,
},
}

Expand Down
55 changes: 37 additions & 18 deletions crates/ledgerr-mcp/src/crypto.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
//! Thin MCP handlers for crypto cost basis rules (gh#516).

use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde_json::{json, Value};

use ledger_core::crypto::{Chain, CostBasisMethod, CryptoTx, CryptoWallet, CryptoCostBasisRules, TaxJurisdiction, TxType};
use ufo_types::{iso::{Currency, Lei}, satisfies::Satisfies};
use ledger_core::crypto::{
Chain, CostBasisMethod, CryptoTx, CryptoWallet, CryptoCostBasisRules, TaxJurisdiction, TxType,
};
use ufo_types::{iso::Currency, satisfies::Satisfies};

pub fn handle_crypto_cost_basis_check(
lei: &str,
Expand All @@ -17,6 +17,9 @@ pub fn handle_crypto_cost_basis_check(
acquisition_date: Option<&str>,
jurisdiction: &str,
currency: &str,
cost_basis_method: &str,
chain: &str,
address: &str,
) -> Value {
let lei = match Lei::new(lei) {
Ok(l) => l,
Expand All @@ -33,35 +36,51 @@ pub fn handle_crypto_cost_basis_check(
};
let acq_date = acquisition_date.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());

let jx = match jurisdiction {
"US" | "us" => TaxJurisdiction::Us,
_ => TaxJurisdiction::Au,
};
let jx = TaxJurisdiction::from_str(jurisdiction);
let ccy = match currency {
"USD" => Currency::Usd,
_ => Currency::Aud,
};
let tt = match tx_type {
"buy" => TxType::Buy, "staking" => TxType::Staking,
"airdrop" => TxType::Airdrop, "spend" => TxType::Spend,
"transfer" => TxType::Transfer,
_ => TxType::Sell,
let tt = TxType::from_str(tx_type);

let ch = match Chain::from_str(chain) {
Some(c) => c,
None => return json!({ "error": format!("unrecognized chain: {chain}") }),
};
let cbm = match CostBasisMethod::from_str(cost_basis_method) {
Some(m) => m,
None => {
return json!({
"error": format!("unrecognized cost_basis_method: {cost_basis_method}")
})
}
};

let wallet = CryptoWallet {
lei, address: String::new(), chain: Chain::Bitcoin,
jurisdiction: jx, cost_basis_method: CostBasisMethod::Fifo,
lei,
address: address.to_string(),
chain: ch,
jurisdiction: jx,
cost_basis_method: cbm,
};
let tx = CryptoTx {
tx_hash: tx_hash.to_string(), wallet, tx_type: tt,
gross_proceeds: gross, cost_basis: cost,
date: tx_date, acquisition_date: acq_date, currency: ccy,
tx_hash: tx_hash.to_string(),
wallet,
tx_type: tt,
gross_proceeds: gross,
cost_basis: cost,
date: tx_date,
acquisition_date: acq_date,
currency: ccy,
};

json!({
"result": tx.satisfies(&CryptoCostBasisRules),
"gain_loss": tx.gain_loss(),
"au_discount_eligible": tx.au_discount_eligible(),
"au_taxable_gain": tx.au_taxable_gain(),
"method_used": tx.wallet.cost_basis_method.as_str(),
"chain": tx.wallet.chain.as_str(),
"address": tx.wallet.address,
})
}
Loading
Loading