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
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ jobs:
cargo test -p basis_server -- --nocapture
cargo test -p basis_offchain -- --nocapture
cargo test -p basis_app -- --nocapture
cargo test -p integration_tests -- --nocapture

- name: Run specific module tests
run: |
Expand Down
27 changes: 27 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[workspace]
members = ["crates/*"]
members = ["crates/*", "crates/integration_tests"]

resolver = "2"

[workspace.dependencies]
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,33 @@ This project uses GitHub Actions for continuous integration. On every commit to

See [.github/workflows/test.yml](.github/workflows/test.yml) for the complete workflow.

## Running Examples

### Agent-to-Agent Payment Showcase
A demonstration of the Celaut payment module where agents establish credit limits and exchange payments.

```bash
cargo run -p integration_tests --example agent_payment_demo
```

### Wallet Bot API Demo
A demonstration of how a wallet (e.g., Telegram bot) uses the Tracker API to query balances and send payments.

```bash
cargo run -p integration_tests --example wallet_bot_demo
```

### SilverCents Demo CLI (Issuance & Redemption)
A comprehensive simulation of the SilverCents workflow:
1. Vendor creates reserve backed by ERG + DexySilver tokens.
2. Vendor issues SilverCent notes for goods ("Organic Apples").
3. Customer redeems SilverCent notes for physical quarters.

**Run the CLI Demo:**
```bash
cargo run -p integration_tests --example silvercents_demo
```

## Implementation Roadmap

The following implementation plan is targeting catching micropayments in P2P networks, agentic networks, etc ASAP and then
Expand Down
18 changes: 18 additions & 0 deletions contract/basis_token.es
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Basis Reserve Contract - Token Variant
//
// This contract guards the reserve collateral (Tokens + ERG) and ensures that:
// 1. Redemptions are valid (signed by issuer, valid proof from tracker)
// 2. State updates are authorized by the owner
//
// This variant supports reserves where the primary collateral is a token.

{
// Same logic as standard Basis reserve for now, but intended for token boxes
// R4: Owner Public Key
// R5: Tracker NFT ID (optional)

val ownerPubKey = extract(SELF.R4[Coll[Byte]].get)

// simple owner spend for now
proveDlog(ownerPubKey)
}
21 changes: 11 additions & 10 deletions crates/basis_server/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -725,23 +725,24 @@ pub async fn get_key_status(
.into_iter()
.find(|reserve| reserve.owner_pubkey == pubkey_hex);

let (collateral, collateralization_ratio, last_updated) = if let Some(reserve) = reserve {
let collateral = reserve.base_info.collateral_amount;
let ratio = if total_debt > 0 {
collateral as f64 / total_debt as f64
} else {
// Use a very high ratio when there's no debt
999999.0
};
(collateral, ratio, reserve.last_updated_timestamp)
let (collateral, collateralization_ratio, last_updated, token_id, token_amount) = if let Some(reserve) = reserve {
(
reserve.base_info.collateral_amount,
reserve.collateralization_ratio(),
reserve.last_updated_timestamp,
reserve.base_info.token_id.clone(),
reserve.base_info.token_amount,
)
} else {
// No reserve found - use zero collateral
(0, if total_debt > 0 { 0.0 } else { 999999.0 }, 0)
(0, if total_debt > 0 { 0.0 } else { 999999.0 }, 0, None, None)
};

let status = KeyStatusResponse {
total_debt,
collateral,
token_id,
token_amount,
collateralization_ratio,
note_count,
last_updated,
Expand Down
5 changes: 4 additions & 1 deletion crates/basis_server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ pub mod config;
pub mod models;
pub mod reserve_api;
pub mod store;
pub mod wallet_api;

/* ... */
pub mod tracker_box_updater;

#[cfg(test)]
Expand Down Expand Up @@ -53,7 +56,7 @@ pub enum TrackerCommand {
GetNotesByRecipient {
recipient_pubkey: basis_store::PubKey,
response_tx:
tokio::sync::oneshot::Sender<Result<Vec<basis_store::IouNote>, basis_store::NoteError>>,
tokio::sync::oneshot::Sender<Result<Vec<(basis_store::PubKey, basis_store::IouNote)>, basis_store::NoteError>>,
},
GetNoteByIssuerAndRecipient {
issuer_pubkey: basis_store::PubKey,
Expand Down
12 changes: 11 additions & 1 deletion crates/basis_server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use axum::{
use basis_server::{
api::*, reserve_api::*, store::EventStore, AppConfig, AppState, ErgoConfig, EventType,
ServerConfig, TrackerCommand, TrackerEvent, TransactionConfig,
TrackerBoxUpdateConfig, TrackerBoxUpdater, SharedTrackerState,
TrackerBoxUpdateConfig, TrackerBoxUpdater, SharedTrackerState, wallet_api::*,
};
use basis_store::{
ergo_scanner::{start_scanner, NodeConfig, ReserveEvent, ServerState},
Expand Down Expand Up @@ -484,6 +484,16 @@ async fn main() {
tracing::debug!(" POST /redeem");
tracing::debug!(" GET /proof");

// Wallet API Routes
let app = app
.route("/wallet/pay", post(send_payment).options(handle_options))
.route("/wallet/{pubkey}/summary", get(get_wallet_summary))
.route("/wallet/{pubkey}/history", get(get_wallet_history));

tracing::debug!(" POST /wallet/pay");
tracing::debug!(" GET /wallet/{{pubkey}}/summary");
tracing::debug!(" GET /wallet/{{pubkey}}/history");

// Run our app with hyper
let addr = config.socket_addr();
tracing::debug!("listening on {}", addr);
Expand Down
2 changes: 2 additions & 0 deletions crates/basis_server/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ impl From<IouNote> for SerializableIouNote {
pub struct KeyStatusResponse {
pub total_debt: u64,
pub collateral: u64,
pub token_id: Option<String>,
pub token_amount: Option<u64>,
pub collateralization_ratio: f64,
pub note_count: usize,
pub last_updated: u64,
Expand Down
199 changes: 199 additions & 0 deletions crates/basis_server/src/wallet_api.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
use axum::{extract::State, http::StatusCode, Json};
use serde::{Deserialize, Serialize};

use crate::{
models::{ApiResponse, CreateNoteRequest, SerializableIouNote, TrackerEvent, EventType},
AppState, TrackerCommand,
};
use basis_store::NoteError;

// --- Models ---

#[derive(Debug, Serialize)]
pub struct WalletSummary {
pub pubkey: String,
pub total_debt: u64,
pub collateral: u64,
pub collateralization_ratio: f64,
pub token_id: Option<String>,
pub token_amount: Option<u64>,
pub note_count: usize,
pub recent_activity: Vec<WalletActivityItem>,
}

#[derive(Debug, Serialize)]
pub struct WalletActivityItem {
pub timestamp: u64,
pub activity_type: String, // "incoming_note", "outgoing_note", "redemption", etc.
pub other_party: String, // Pubkey of sender/receiver
pub amount: u64,
pub details: String,
}

#[derive(Debug, Serialize)]
pub struct WalletHistory {
pub incoming_notes: Vec<SerializableIouNote>,
pub outgoing_notes: Vec<SerializableIouNote>,
}

#[derive(Debug, Deserialize)]
pub struct SimplePaymentRequest {
pub sender_pubkey: String,
pub recipient_pubkey: String,
pub amount: u64,
pub timestamp: u64,
pub signature: String, // Hex encoded signature of the IOU note
}

// --- Handlers ---

/// Get a unified wallet summary (balance + status + recent)
#[axum::debug_handler]
pub async fn get_wallet_summary(
State(state): State<AppState>,
axum::extract::Path(pubkey_hex): axum::extract::Path<String>,
) -> (StatusCode, Json<ApiResponse<WalletSummary>>) {
// 1. reuse logic from get_key_status for collateral/debt
// Call the internal logic of get_key_status (we can't call the handler directly easily, so we duplicate the lightweight logic or refactor core logic later. For now, logic duplication is acceptable for "thin layer").

// a. Validate Key
let pubkey_bytes = match hex::decode(&pubkey_hex) {
Ok(b) if b.len() == 33 => b,
_ => return (StatusCode::BAD_REQUEST, Json(crate::models::error_response("Invalid pubkey".into()))),
};
let issuer_pubkey: basis_store::PubKey = match pubkey_bytes.try_into() {
Ok(k) => k,
Err(_) => return (StatusCode::BAD_REQUEST, Json(crate::models::error_response("Invalid pubkey length".into()))),
};

// b. Get Debt (Outgoing Notes)
let (tx_out, rx_out) = tokio::sync::oneshot::channel();
let _ = state.tx.send(TrackerCommand::GetNotesByIssuer {
issuer_pubkey,
response_tx: tx_out
}).await;

let outgoing_notes = match rx_out.await {
Ok(Ok(notes)) => notes,
_ => vec![],
};

let total_debt: u64 = outgoing_notes.iter().map(|n| n.outstanding_debt()).sum();
let note_count = outgoing_notes.len();

// c. Get Collateral
let tracker = state.reserve_tracker.lock().await;
let reserves = tracker.get_all_reserves();
let reserve = reserves.into_iter().find(|r| r.owner_pubkey == pubkey_hex);

let (collateral, ratio, _, token_id, token_amount) = if let Some(r) = reserve {
(
r.base_info.collateral_amount,
r.collateralization_ratio(),
r.last_updated_timestamp,
r.base_info.token_id.clone(),
r.base_info.token_amount,
)
} else {
(0, if total_debt > 0 { 0.0 } else { 999999.0 }, 0, None, None)
};

// d. Get Recent Activity (from outgoing notes + maybe incoming notes)
// For a simple summary, we list the last 5 outgoing notes created.
// In a full implementation, we'd also query incoming notes and merge them.
let mut recent_activity = Vec::new();

// Convert outgoing notes to activity
for note in outgoing_notes.iter().rev().take(5) {
recent_activity.push(WalletActivityItem {
timestamp: note.timestamp,
activity_type: "outgoing_payment".to_string(),
other_party: hex::encode(note.recipient_pubkey),
amount: note.amount_collected,
details: "Issued IOU note".to_string(),
});
}

let summary = WalletSummary {
pubkey: pubkey_hex,
total_debt,
collateral,
collateralization_ratio: ratio,
token_id,
token_amount,
note_count,
recent_activity,
};

(StatusCode::OK, Json(crate::models::success_response(summary)))
}

/// Get simplified wallet history
#[axum::debug_handler]
pub async fn get_wallet_history(
State(state): State<AppState>,
axum::extract::Path(pubkey_hex): axum::extract::Path<String>,
) -> (StatusCode, Json<ApiResponse<WalletHistory>>) {
// 1. Validate Key
let pubkey_bytes = match hex::decode(&pubkey_hex) {
Ok(b) if b.len() == 33 => b,
_ => return (StatusCode::BAD_REQUEST, Json(crate::models::error_response("Invalid pubkey".into()))),
};
let pubkey: basis_store::PubKey = match pubkey_bytes.try_into() {
Ok(k) => k,
Err(_) => return (StatusCode::BAD_REQUEST, Json(crate::models::error_response("Invalid pubkey length".into()))),
};

// 2. Get Outgoing
let (tx_out, rx_out) = tokio::sync::oneshot::channel();
let _ = state.tx.send(TrackerCommand::GetNotesByIssuer {
issuer_pubkey: pubkey,
response_tx: tx_out
}).await;
let outgoing = rx_out.await.unwrap_or(Ok(vec![])).unwrap_or(vec![]);

// 3. Get Incoming
let (tx_in, rx_in) = tokio::sync::oneshot::channel();
let _ = state.tx.send(TrackerCommand::GetNotesByRecipient {
recipient_pubkey: pubkey,
response_tx: tx_in
}).await;
let incoming = rx_in.await.unwrap_or(Ok(vec![])).unwrap_or(vec![]);

// 4. Transform
let history = WalletHistory {
outgoing_notes: outgoing.into_iter().map(|n| {
let mut sn = SerializableIouNote::from(n);
sn.issuer_pubkey = pubkey_hex.clone();
sn
}).collect(),
incoming_notes: incoming.into_iter().map(|(issuer, n)| {
let mut sn = SerializableIouNote::from(n);
sn.issuer_pubkey = hex::encode(issuer);
sn
}).collect(),
};

(StatusCode::OK, Json(crate::models::success_response(history)))
}

/// Simple payment endpoint (Wrapper around CreateNote)
#[axum::debug_handler]
pub async fn send_payment(
State(state): State<AppState>,
Json(payload): Json<SimplePaymentRequest>,
) -> (StatusCode, Json<ApiResponse<()>>) {
// Just delegate to create_note logic logic via `api::create_note` handler is tricky directly.
// So we recreate the request.

let note_req = CreateNoteRequest {
issuer_pubkey: payload.sender_pubkey,
recipient_pubkey: payload.recipient_pubkey,
amount: payload.amount,
timestamp: payload.timestamp,
signature: payload.signature,
};

// Call the same logic as create_note
crate::api::create_note(State(state), Json(note_req)).await
}
Loading