Skip to content
Merged
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
53 changes: 53 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: ci

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0

jobs:
fmt:
name: cargo fmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --all -- --check

clippy:
name: cargo clippy
runs-on: ubuntu-latest
needs: fmt
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
# No `-D warnings` — jsonic carries pre-existing dead_code +
# naming-convention warnings (jsonic-demo binary) that should be
# paid down deliberately, not fixed in the same PR that wires
# CI. Tighten to `-D warnings` once the backlog is resolved.
- run: cargo clippy --all-targets

test:
name: cargo test
runs-on: ubuntu-latest
needs: fmt
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo test --all-targets
39 changes: 24 additions & 15 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,19 @@
use std::sync::Arc;

use axum::{
Router,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Json},
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;

use crate::core::dao::DAORegistry;
use crate::core::heartbeat::JsonicNode;
use crate::core::reputation::{compute_pagerank, NodeId, PageRankConfig};
use crate::core::types::{BalanceSheet, DAOId, MainChainBlock, NetworkMetrics, Transaction, DAO};
use crate::core::reputation::{NodeId, PageRankConfig, compute_pagerank};
use crate::core::types::{BalanceSheet, DAO, DAOId, MainChainBlock, NetworkMetrics, Transaction};

/// Shared, thread-safe handle to a Jsonic node.
pub type SharedNode = Arc<RwLock<JsonicNode>>;
Expand Down Expand Up @@ -115,14 +115,14 @@ async fn health(State(node): State<SharedNode>) -> Json<HealthResponse> {
})
}

async fn register_dao(
State(node): State<SharedNode>,
Json(dao): Json<DAO>,
) -> impl IntoResponse {
async fn register_dao(State(node): State<SharedNode>, Json(dao): Json<DAO>) -> impl IntoResponse {
let id = dao.id.clone();
let mut guard = node.write().await;
guard.register_dao(dao);
(StatusCode::CREATED, Json(RegisterDaoResponse { dao_id: id }))
(
StatusCode::CREATED,
Json(RegisterDaoResponse { dao_id: id }),
)
}

async fn submit_transaction(
Expand Down Expand Up @@ -187,8 +187,7 @@ async fn get_metrics(
None => Err((
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "no main-chain blocks yet; run a heartbeat past a Solstice"
.to_string(),
error: "no main-chain blocks yet; run a heartbeat past a Solstice".to_string(),
}),
)),
}
Expand Down Expand Up @@ -228,7 +227,10 @@ async fn get_reputation(
Path(dao_id): Path<DAOId>,
) -> Json<ReputationResponse> {
let guard = node.read().await;
let scores = compute_pagerank(&guard.main_chain.reputation_graph, &PageRankConfig::default());
let scores = compute_pagerank(
&guard.main_chain.reputation_graph,
&PageRankConfig::default(),
);
let key = NodeId::DAO(dao_id.clone());
let pr = scores.pagerank.get(&key).copied().unwrap_or(0.0);
let trust = (pr - scores.baseline_rank).max(0.0);
Expand All @@ -252,9 +254,9 @@ fn registered(registry: &DAORegistry, dao_id: &DAOId) -> bool {
mod tests {
use super::*;
use crate::core::dao::RegisteredDAO;
use axum::body::{to_bytes, Body};
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use serde_json::{json, Value};
use serde_json::{Value, json};
use tower::ServiceExt;

fn fresh_node() -> SharedNode {
Expand All @@ -264,7 +266,9 @@ mod tests {
}

async fn body_json(resp: axum::response::Response) -> Value {
let bytes = to_bytes(resp.into_body(), usize::MAX).await.expect("read body");
let bytes = to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read body");
serde_json::from_slice(&bytes).expect("parse json")
}

Expand All @@ -273,7 +277,12 @@ mod tests {
let node = fresh_node();
let app = build_router(node);
let resp = app
.oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap())
.oneshot(
Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
Expand Down
7 changes: 5 additions & 2 deletions src/bin/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
use std::env;
use std::sync::Arc;

use jsonic_protocol::api::{build_router, SharedNode};
use jsonic_protocol::api::{SharedNode, build_router};
use jsonic_protocol::core::heartbeat::JsonicNode;
use jsonic_protocol::core::store::{ChainStore, SledStore};
use tokio::net::TcpListener;
Expand All @@ -34,7 +34,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
);
node.main_chain = restored;
} else {
eprintln!("[jsonic-rpc] no prior chain at {}, starting fresh", data_dir);
eprintln!(
"[jsonic-rpc] no prior chain at {}, starting fresh",
data_dir
);
}

let shared: SharedNode = Arc::new(RwLock::new(node));
Expand Down
2 changes: 1 addition & 1 deletion src/core/crypto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ mod tests {
#[test]
fn test_merkle_root_single() {
let h = sha256_str("tx1");
let root = merkle_root(&[h.clone()]);
let root = merkle_root(std::slice::from_ref(&h));
assert_eq!(root, h);
}

Expand Down
14 changes: 4 additions & 10 deletions src/core/dao.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@

use chrono::Utc;

use super::crypto::{derive_dao_id, generate_keypair, sign, KeyPair};
use super::types::{
Transaction, TransactionStatus, TransactionType, DAOId, DAO, DAOProfile,
};
use super::crypto::{KeyPair, derive_dao_id, generate_keypair, sign};
use super::types::{DAO, DAOId, DAOProfile, Transaction, TransactionStatus, TransactionType};

/// A registered DAO with its private signing key.
/// The signing key never leaves the DAO's node in a real deployment.
Expand Down Expand Up @@ -228,12 +226,8 @@ mod tests {
let mut sender = RegisteredDAO::register("Acme Corp", "Technology");
let receiver = RegisteredDAO::register("Globex Inc", "Manufacturing");

let invoice = sender.create_invoice(
receiver.id(),
50_000.0,
"USD",
"Q1 consulting services",
);
let invoice =
sender.create_invoice(receiver.id(), 50_000.0, "USD", "Q1 consulting services");

assert_eq!(invoice.tx_type, TransactionType::Invoice);
assert_eq!(invoice.from, *sender.id());
Expand Down
15 changes: 5 additions & 10 deletions src/core/heartbeat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use super::dao::DAORegistry;
use super::mainchain::MainChain;
use super::pot;
use super::sidechain::SideChain;
use super::types::{DAOId, Transaction, TransactionStatus, TokenDistribution};
use super::types::{DAOId, TokenDistribution, Transaction, TransactionStatus};

use std::collections::HashMap;

Expand Down Expand Up @@ -80,7 +80,7 @@ impl JsonicNode {
self.process_matching();

// Check if it's time for Solstice
if self.tick % self.solstice_interval == 0 {
if self.tick.is_multiple_of(self.solstice_interval) {
Some(self.execute_solstice())
} else {
None
Expand Down Expand Up @@ -110,9 +110,7 @@ impl JsonicNode {
(tx_j, tx_i, j, i)
};

if let pot::POTVerdict::Settled =
pot::settle_invoice(invoice, payment)
{
if let pot::POTVerdict::Settled = pot::settle_invoice(invoice, payment) {
settlements.push((inv_idx, pay_idx));
self.main_chain.record_transaction_outcome(true);
continue;
Expand Down Expand Up @@ -146,11 +144,8 @@ impl JsonicNode {

// Buyer (payment_from) -> Seller (invoice_from): edge in the
// reputation graph, weighted by the settled value.
self.main_chain.record_settled_transaction(
&payment_from,
&invoice_from,
settled_value,
);
self.main_chain
.record_settled_transaction(&payment_from, &invoice_from, settled_value);

// Update invoice status to Settled on the issuer's side-chain
if let Some(chain) = self.side_chains.get_mut(&invoice_from) {
Expand Down
18 changes: 5 additions & 13 deletions src/core/mainchain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize};
use super::crypto::{merkle_root, sha256_str};
use super::pot;
use super::reputation::{
compute_dao_reward, compute_pagerank, NodeId, PageRankConfig, ReputationGraph,
NodeId, PageRankConfig, ReputationGraph, compute_dao_reward, compute_pagerank,
};
use super::types::{
BlockHeader, DAOId, DAOSnapshot, Hash, MainChainBlock, NetworkMetrics, TokenDistribution,
Expand Down Expand Up @@ -107,10 +107,8 @@ impl MainChain {

// Compute network metrics
let anxiety = pot::compute_anxiety(self.total_transactions, self.invalid_transactions);
let adrenaline = pot::compute_adrenaline(
self.current_heartbeat_tx_count,
TARGET_TX_PER_HEARTBEAT,
);
let adrenaline =
pot::compute_adrenaline(self.current_heartbeat_tx_count, TARGET_TX_PER_HEARTBEAT);
let heartbeat_ms = pot::adjusted_heartbeat_ms(BASE_HEARTBEAT_MS, adrenaline);

let network_metrics = NetworkMetrics {
Expand Down Expand Up @@ -139,10 +137,7 @@ impl MainChain {
/// - Volume of matched transactions
/// - Value of matched transactions
/// - Ratio of matched vs unmatched (low Anxiety contribution)
fn compute_token_distribution(
&self,
snapshots: &[DAOSnapshot],
) -> Vec<TokenDistribution> {
fn compute_token_distribution(&self, snapshots: &[DAOSnapshot]) -> Vec<TokenDistribution> {
let total_relevance: f64 = snapshots.iter().map(|s| s.relevance_score).sum();

if total_relevance == 0.0 {
Expand Down Expand Up @@ -312,10 +307,7 @@ mod tests {
#[test]
fn test_no_tokens_when_no_activity() {
let mut chain = MainChain::new();
let snapshots = vec![
make_snapshot("dao1", 0, 0.0),
make_snapshot("dao2", 0, 0.0),
];
let snapshots = vec![make_snapshot("dao1", 0, 0.0), make_snapshot("dao2", 0, 0.0)];

let distributions = chain.solstice(snapshots);

Expand Down
41 changes: 14 additions & 27 deletions src/core/pot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,7 @@ pub fn verify_sequence(tx: &Transaction, expected_next: u64) -> bool {
/// For an invoice from DAO-A to DAO-B to be "matched", DAO-B must have a
/// corresponding acknowledgment. In practice this means both DAOs recorded
/// the same transaction (same ID, same amount, matching from/to).
pub fn match_transactions(
sender_tx: &Transaction,
receiver_tx: &Transaction,
) -> POTVerdict {
pub fn match_transactions(sender_tx: &Transaction, receiver_tx: &Transaction) -> POTVerdict {
// Must reference the same transaction ID
if sender_tx.id != receiver_tx.id {
return POTVerdict::Invalid("Transaction IDs do not match".to_string());
Expand Down Expand Up @@ -96,10 +93,7 @@ pub fn match_transactions(
/// - The payment amount matches the invoice amount
/// - The payment is from the invoice's `to` DAO (the debtor pays)
/// - The payment is to the invoice's `from` DAO (the creditor receives)
pub fn settle_invoice(
invoice: &Transaction,
payment: &Transaction,
) -> POTVerdict {
pub fn settle_invoice(invoice: &Transaction, payment: &Transaction) -> POTVerdict {
if invoice.tx_type != TransactionType::Invoice {
return POTVerdict::Invalid("First transaction is not an Invoice".to_string());
}
Expand All @@ -117,24 +111,18 @@ pub fn settle_invoice(
));
}
None => {
return POTVerdict::Invalid(
"Payment does not reference any invoice".to_string(),
);
return POTVerdict::Invalid("Payment does not reference any invoice".to_string());
}
}

// The payer (payment.from) should be the invoice's recipient (invoice.to)
if payment.from != invoice.to {
return POTVerdict::Invalid(
"Payment sender does not match invoice recipient".to_string(),
);
return POTVerdict::Invalid("Payment sender does not match invoice recipient".to_string());
}

// The payee (payment.to) should be the invoice issuer (invoice.from)
if payment.to != invoice.from {
return POTVerdict::Invalid(
"Payment recipient does not match invoice issuer".to_string(),
);
return POTVerdict::Invalid("Payment recipient does not match invoice issuer".to_string());
}

// Amounts must match
Expand Down Expand Up @@ -206,7 +194,7 @@ mod tests {
#[test]
fn test_match_transactions_success() {
let mut dao_a = RegisteredDAO::register("DAO-A", "Tech");
let mut dao_b = RegisteredDAO::register("DAO-B", "Finance");
let dao_b = RegisteredDAO::register("DAO-B", "Finance");

let invoice = dao_a.create_invoice(dao_b.id(), 5000.0, "USD", "Services");

Expand All @@ -215,8 +203,13 @@ mod tests {
ack.signature = crate::core::crypto::sign(
format!(
"{}:{}:{}:{}:{}:{}:{}",
ack.id, ack.from, ack.to, ack.amount, ack.currency,
ack.sequence_number, ack.timestamp.to_rfc3339()
ack.id,
ack.from,
ack.to,
ack.amount,
ack.currency,
ack.sequence_number,
ack.timestamp.to_rfc3339()
)
.as_bytes(),
&dao_b.keypair.signing_key,
Expand Down Expand Up @@ -265,13 +258,7 @@ mod tests {

let invoice = dao_a.create_invoice(dao_b.id(), 10_000.0, "EUR", "Services");
// DAO-C tries to pay an invoice meant for DAO-B
let payment = dao_c.create_payment(
dao_a.id(),
10_000.0,
"EUR",
&invoice.id,
"Wrong payer",
);
let payment = dao_c.create_payment(dao_a.id(), 10_000.0, "EUR", &invoice.id, "Wrong payer");

let verdict = settle_invoice(&invoice, &payment);
assert!(matches!(verdict, POTVerdict::Invalid(_)));
Expand Down
Loading
Loading