use actix_web::{web, Error as ActixError, HttpRequest, HttpResponse};
use async_trait::async_trait;
use bytes::Bytes;
use serde::Deserialize;
use solid_pod_rs::bitcoin_tx::{
anchor_proof_json, build_withdraw_voucher, parse_txo_voucher, transfer_token_with_key,
MempoolBroadcast, DEFAULT_FEE_SATS,
};
use solid_pod_rs::mrc20::{bt_address, verify_mrc20_anchor, Mrc20State};
use solid_pod_rs::payments::{
balance_response, parse_txo_uri, payment_required_body, PaymentError, PaymentStore, WebLedger,
};
use solid_pod_rs::storage::Storage;
use solid_pod_rs::trading::{AmmPool, Exchange};
use crate::mempool::MempoolHttpClient;
use crate::trail_store::{load_trail, save_trail};
use crate::{agent_uri, extract_pubkey, AppState, WEBLEDGER_PATH};
fn network_for_chain(chain: &str) -> &'static str {
match chain {
"btc" => "mainnet",
"tbtc3" => "testnet",
_ => "testnet4",
}
}
const REPLAY_PATH: &str = "/.well-known/webledgers/replay.json";
const OFFERS_PATH: &str = "/.well-known/webledgers/offers.json";
const POOL_PATH: &str = "/.well-known/webledgers/pool.json";
pub struct StoragePaymentStore<'a> {
storage: &'a dyn Storage,
ledger_name: String,
}
impl<'a> StoragePaymentStore<'a> {
pub fn new(storage: &'a dyn Storage) -> Self {
Self {
storage,
ledger_name: "Pod Credits".to_string(),
}
}
async fn read_replay_set(&self) -> Result<Vec<String>, PaymentError> {
match self.storage.get(REPLAY_PATH).await {
Ok((bytes, _meta)) => serde_json::from_slice::<Vec<String>>(&bytes)
.map_err(|e| PaymentError::Store(format!("malformed replay set: {e}"))),
Err(_) => Ok(Vec::new()),
}
}
async fn write_replay_set(&self, set: &[String]) -> Result<(), PaymentError> {
let body = serde_json::to_vec(set)
.map_err(|e| PaymentError::Store(format!("serialise replay set: {e}")))?;
self.storage
.put(REPLAY_PATH, Bytes::from(body), "application/json")
.await
.map_err(|e| PaymentError::Store(e.to_string()))?;
Ok(())
}
}
#[async_trait(?Send)]
impl<'a> PaymentStore for StoragePaymentStore<'a> {
async fn read_ledger(&self) -> Result<WebLedger, PaymentError> {
match self.storage.get(WEBLEDGER_PATH).await {
Ok((bytes, _meta)) => serde_json::from_slice::<WebLedger>(&bytes)
.map_err(|e| PaymentError::Store(format!("malformed ledger: {e}"))),
Err(_) => Ok(WebLedger::new(&self.ledger_name)),
}
}
async fn write_ledger(&self, ledger: &WebLedger) -> Result<(), PaymentError> {
let body = serde_json::to_vec(ledger)
.map_err(|e| PaymentError::Store(format!("serialise ledger: {e}")))?;
self.storage
.put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
.await
.map_err(|e| PaymentError::Store(e.to_string()))?;
Ok(())
}
async fn check_replay(&self, key: &str) -> Result<bool, PaymentError> {
let set = self.read_replay_set().await?;
Ok(set.iter().any(|k| k == key))
}
async fn record_replay(&self, key: &str) -> Result<(), PaymentError> {
let mut set = self.read_replay_set().await?;
if !set.iter().any(|k| k == key) {
set.push(key.to_string());
self.write_replay_set(&set).await?;
}
Ok(())
}
}
async fn load_order_book(
storage: &dyn Storage,
) -> Result<solid_pod_rs::trading::OrderBook, ActixError> {
match storage.get(OFFERS_PATH).await {
Ok((bytes, _meta)) => serde_json::from_slice(&bytes)
.map_err(|e| actix_web::error::ErrorInternalServerError(format!("malformed offers: {e}"))),
Err(_) => Ok(solid_pod_rs::trading::OrderBook::new()),
}
}
async fn save_order_book(
storage: &dyn Storage,
book: &solid_pod_rs::trading::OrderBook,
) -> Result<(), ActixError> {
let body = serde_json::to_vec(book)
.map_err(|e| actix_web::error::ErrorInternalServerError(format!("serialise offers: {e}")))?;
storage
.put(OFFERS_PATH, Bytes::from(body), "application/json")
.await
.map_err(crate::to_actix)?;
Ok(())
}
async fn load_exchange(storage: &dyn Storage) -> Result<Exchange, ActixError> {
match storage.get(POOL_PATH).await {
Ok((bytes, _meta)) => serde_json::from_slice(&bytes)
.map_err(|e| actix_web::error::ErrorInternalServerError(format!("malformed pool: {e}"))),
Err(_) => Ok(Exchange::new()),
}
}
async fn save_exchange(storage: &dyn Storage, exchange: &Exchange) -> Result<(), ActixError> {
let body = serde_json::to_vec(exchange)
.map_err(|e| actix_web::error::ErrorInternalServerError(format!("serialise pool: {e}")))?;
storage
.put(POOL_PATH, Bytes::from(body), "application/json")
.await
.map_err(crate::to_actix)?;
Ok(())
}
async fn require_did(req: &HttpRequest) -> Result<String, HttpResponse> {
let pubkey = extract_pubkey(req).await;
match agent_uri(pubkey.as_ref()) {
Some(did) => Ok(did),
None => Err(HttpResponse::Unauthorized()
.json(serde_json::json!({ "error": "NIP-98 authentication required" }))),
}
}
fn payment_error_response(err: PaymentError) -> HttpResponse {
match err {
PaymentError::InsufficientBalance { balance, cost } => HttpResponse::PaymentRequired()
.json(payment_required_body(balance, cost)),
PaymentError::Replay(msg) => {
HttpResponse::BadRequest().json(serde_json::json!({ "error": format!("Replay: {msg}") }))
}
PaymentError::InvalidTxo(msg) | PaymentError::InvalidState(msg) => {
HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
}
PaymentError::Store(msg) => HttpResponse::InternalServerError()
.json(serde_json::json!({ "error": format!("payment store: {msg}") })),
}
}
async fn handle_balance(
req: HttpRequest,
state: web::Data<AppState>,
) -> Result<HttpResponse, ActixError> {
let did = match require_did(&req).await {
Ok(d) => d,
Err(rsp) => return Ok(rsp),
};
let store = StoragePaymentStore::new(&*state.storage);
let ledger = match store.read_ledger().await {
Ok(l) => l,
Err(e) => return Ok(payment_error_response(e)),
};
let balance = ledger.get_balance(&did);
Ok(HttpResponse::Ok()
.content_type("application/json")
.json(balance_response(&did, balance, state.pay_config.cost_sats)))
}
#[derive(Debug, Deserialize)]
struct DepositBody {
txo: String,
}
#[derive(Debug, Deserialize)]
struct Mrc20AnchorBody {
pubkey: String,
#[serde(rename = "stateStrings")]
state_strings: Vec<String>,
#[serde(default)]
network: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Mrc20DepositBody {
#[allow(dead_code)]
#[serde(rename = "type")]
kind: String,
state: Mrc20State,
#[serde(rename = "prevState")]
prev_state: Mrc20State,
anchor: Mrc20AnchorBody,
}
async fn handle_deposit(
req: HttpRequest,
state: web::Data<AppState>,
body: Bytes,
) -> Result<HttpResponse, ActixError> {
let did = match require_did(&req).await {
Ok(d) => d,
Err(rsp) => return Ok(rsp),
};
if body_is_mrc20(&body) {
return handle_mrc20_deposit(&did, &state, &body).await;
}
let raw = String::from_utf8_lossy(&body);
let txo_uri = match serde_json::from_slice::<DepositBody>(&body) {
Ok(b) => b.txo,
Err(_) => raw.trim().to_string(),
};
let txo = match parse_txo_uri(&txo_uri) {
Ok(t) => t,
Err(e) => return Ok(payment_error_response(e)),
};
let replay_key = format!("{}:{}", txo.txid, txo.vout);
let store = StoragePaymentStore::new(&*state.storage);
match store.check_replay(&replay_key).await {
Ok(true) => {
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": "Replay: this output has already been used for a deposit",
"txid": txo.txid,
"vout": txo.vout,
})));
}
Ok(false) => {}
Err(e) => return Ok(payment_error_response(e)),
}
let amount: u64 = ((txo.vout as u64) + 1) * 1000;
let mut ledger = match store.read_ledger().await {
Ok(l) => l,
Err(e) => return Ok(payment_error_response(e)),
};
ledger.credit(&did, amount);
if let Err(e) = store.write_ledger(&ledger).await {
return Ok(payment_error_response(e));
}
if let Err(e) = store.record_replay(&replay_key).await {
return Ok(payment_error_response(e));
}
let balance = ledger.get_balance(&did);
Ok(HttpResponse::Ok().content_type("application/json").json(serde_json::json!({
"did": did,
"deposited": amount,
"balance": balance,
"unit": "sat",
"txid": txo.txid,
"vout": txo.vout,
})))
}
fn body_is_mrc20(body: &[u8]) -> bool {
serde_json::from_slice::<serde_json::Value>(body)
.ok()
.and_then(|v| {
v.get("type")
.and_then(|t| t.as_str())
.map(|s| s.eq_ignore_ascii_case("mrc20"))
})
.unwrap_or(false)
}
fn pod_issuer_pubkey(state: &AppState) -> Option<String> {
state
.pay_config
.token
.as_ref()
.map(|t| t.issuer.clone())
.filter(|p| !p.is_empty())
}
async fn handle_mrc20_deposit(
did: &str,
state: &AppState,
body: &[u8],
) -> Result<HttpResponse, ActixError> {
let deposit: Mrc20DepositBody = match serde_json::from_slice(body) {
Ok(d) => d,
Err(e) => {
return Ok(HttpResponse::BadRequest()
.json(serde_json::json!({ "error": format!("malformed mrc20 deposit: {e}") })))
}
};
let issuer = match pod_issuer_pubkey(state) {
Some(p) => p,
None => {
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": "MRC20 deposits not configured (no token issuer set)"
})))
}
};
let network = deposit
.anchor
.network
.clone()
.unwrap_or_else(|| "testnet4".to_string());
let to_address = match bt_address(&issuer, &[], &network) {
Ok(a) => a,
Err(e) => return Ok(payment_error_response(e)),
};
let state_value = match serde_json::to_value(&deposit.state) {
Ok(v) => v,
Err(e) => {
return Ok(HttpResponse::BadRequest()
.json(serde_json::json!({ "error": format!("serialize state: {e}") })))
}
};
let state_hash = solid_pod_rs::mrc20::sha256_hex(&solid_pod_rs::mrc20::jcs(&state_value));
let replay_key = format!("mrc20:{state_hash}");
let store = StoragePaymentStore::new(&*state.storage);
match store.check_replay(&replay_key).await {
Ok(true) => {
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": "Replay: this state has already been used for a deposit",
"state_hash": state_hash,
})))
}
Ok(false) => {}
Err(e) => return Ok(payment_error_response(e)),
}
let mempool = match &state.mempool_url {
Some(url) => MempoolHttpClient::new(url.clone()),
None => MempoolHttpClient::from_env(),
};
let result = match verify_mrc20_anchor(
&deposit.state,
&deposit.prev_state,
&to_address,
&deposit.anchor.pubkey,
&deposit.anchor.state_strings,
&network,
&mempool,
)
.await
{
Ok(r) => r,
Err(e) => return Ok(payment_error_response(e)),
};
let mut ledger = match store.read_ledger().await {
Ok(l) => l,
Err(e) => return Ok(payment_error_response(e)),
};
ledger.credit(did, result.amount);
if let Err(e) = store.write_ledger(&ledger).await {
return Ok(payment_error_response(e));
}
if let Err(e) = store.record_replay(&replay_key).await {
return Ok(payment_error_response(e));
}
let balance = ledger.get_balance(did);
Ok(HttpResponse::Ok().content_type("application/json").json(serde_json::json!({
"did": did,
"deposited": result.amount,
"ticker": result.ticker,
"balance": balance,
"unit": "token",
"anchor": result.address,
})))
}
#[derive(Debug, Deserialize)]
pub struct AddressQuery {
user: Option<String>,
chain: Option<String>,
}
async fn handle_address(
state: web::Data<AppState>,
query: web::Query<AddressQuery>,
) -> Result<HttpResponse, ActixError> {
let issuer = match pod_issuer_pubkey(&state) {
Some(p) => p,
None => {
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": "Deposit addresses not configured (no token issuer set)"
})))
}
};
let chain = query
.chain
.clone()
.or_else(|| state.pay_config.chains.first().map(|c| c.id.clone()))
.unwrap_or_else(|| "tbtc4".to_string());
if !state.pay_config.chains.is_empty()
&& !state.pay_config.chains.iter().any(|c| c.id == chain)
{
let enabled: Vec<&str> = state.pay_config.chains.iter().map(|c| c.id.as_str()).collect();
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("Chain not enabled: {chain}"),
"enabledChains": enabled,
})));
}
let network = network_for_chain(&chain);
let user = query.user.as_ref().map(|u| u.trim().to_lowercase());
if let Some(u) = &user {
if !is_valid_did_nostr(u) {
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": "Invalid user DID. Expected: did:nostr:<64-hex>"
})));
}
}
let states: Vec<String> = match &user {
Some(u) => vec![u.clone()],
None => vec![],
};
let address = match bt_address(&issuer, &states, network) {
Ok(a) => a,
Err(e) => return Ok(payment_error_response(e)),
};
let mut response = serde_json::json!({
"address": address,
"chain": chain,
"pubkey": issuer,
});
if let Some(u) = &user {
response["user"] = serde_json::Value::String(u.clone());
}
Ok(HttpResponse::Ok().content_type("application/json").json(response))
}
fn is_valid_did_nostr(did: &str) -> bool {
if let Some(hex) = did.strip_prefix("did:nostr:") {
hex.len() == 64 && hex.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
} else {
false
}
}
#[derive(Debug, Deserialize)]
pub struct OffersQuery {
sell: Option<String>,
buy: Option<String>,
}
async fn handle_offers(
state: web::Data<AppState>,
query: web::Query<OffersQuery>,
) -> Result<HttpResponse, ActixError> {
let book = load_order_book(&*state.storage).await?;
let pair = match (query.sell.as_deref(), query.buy.as_deref()) {
(Some(s), Some(b)) => Some((s, b)),
_ => None,
};
let offers = book.list_offers(pair);
Ok(HttpResponse::Ok()
.content_type("application/json")
.json(offers))
}
#[derive(Debug, Deserialize)]
struct SellBody {
sell_currency: String,
sell_amount: u64,
buy_currency: String,
price: u64,
}
async fn handle_sell(
req: HttpRequest,
state: web::Data<AppState>,
body: web::Json<SellBody>,
) -> Result<HttpResponse, ActixError> {
let seller = match require_did(&req).await {
Ok(d) => d,
Err(rsp) => return Ok(rsp),
};
if body.sell_amount == 0 || body.price == 0 {
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": "Specify sell_amount (>0) and price (>0)"
})));
}
let mut book = load_order_book(&*state.storage).await?;
let order = book.create_order(
&seller,
&body.sell_currency,
body.sell_amount,
&body.buy_currency,
body.price,
);
save_order_book(&*state.storage, &book).await?;
Ok(HttpResponse::Ok().content_type("application/json").json(order))
}
#[derive(Debug, Deserialize)]
struct SwapBody {
id: String,
}
async fn handle_swap(
req: HttpRequest,
state: web::Data<AppState>,
body: web::Json<SwapBody>,
) -> Result<HttpResponse, ActixError> {
let buyer = match require_did(&req).await {
Ok(d) => d,
Err(rsp) => return Ok(rsp),
};
let store = StoragePaymentStore::new(&*state.storage);
let mut ledger = match store.read_ledger().await {
Ok(l) => l,
Err(e) => return Ok(payment_error_response(e)),
};
let mut book = load_order_book(&*state.storage).await?;
let result = match book.execute_swap(&body.id, &buyer, &mut ledger) {
Ok(r) => r,
Err(e) => return Ok(payment_error_response(e)),
};
if let Err(e) = store.write_ledger(&ledger).await {
return Ok(payment_error_response(e));
}
save_order_book(&*state.storage, &book).await?;
Ok(HttpResponse::Ok().content_type("application/json").json(serde_json::json!({
"swapped": result.amount_out,
"paid": result.amount_in,
"fee": result.fee,
"buyer": buyer,
"new_balance_in": result.new_balance_in,
"new_balance_out": result.new_balance_out,
})))
}
#[derive(Debug, Deserialize)]
pub struct PoolQuery {
a: Option<String>,
b: Option<String>,
}
async fn handle_pool_get(
state: web::Data<AppState>,
query: web::Query<PoolQuery>,
) -> Result<HttpResponse, ActixError> {
let exchange = load_exchange(&*state.storage).await?;
match (query.a.as_deref(), query.b.as_deref()) {
(Some(a), Some(b)) => match exchange.get_pool(a, b) {
Some(pool) => Ok(HttpResponse::Ok()
.content_type("application/json")
.json(pool.pool_info())),
None => Ok(HttpResponse::Ok().content_type("application/json").json(
serde_json::json!({ "currency_a": a, "currency_b": b, "reserve_a": 0, "reserve_b": 0, "total_shares": 0 }),
)),
},
_ => {
let infos: Vec<serde_json::Value> =
exchange.pools.values().map(|p| p.pool_info()).collect();
Ok(HttpResponse::Ok()
.content_type("application/json")
.json(infos))
}
}
}
#[derive(Debug, Deserialize)]
struct PoolOpBody {
action: String,
currency_a: String,
currency_b: String,
#[serde(default)]
amount_a: u64,
#[serde(default)]
amount_b: u64,
#[serde(default)]
shares: u64,
#[serde(default)]
from_currency: Option<String>,
#[serde(default)]
amount_in: u64,
#[serde(default)]
fee_bps: Option<u64>,
}
async fn handle_pool_post(
req: HttpRequest,
state: web::Data<AppState>,
body: web::Json<PoolOpBody>,
) -> Result<HttpResponse, ActixError> {
let provider = match require_did(&req).await {
Ok(d) => d,
Err(rsp) => return Ok(rsp),
};
let store = StoragePaymentStore::new(&*state.storage);
let mut ledger = match store.read_ledger().await {
Ok(l) => l,
Err(e) => return Ok(payment_error_response(e)),
};
let mut exchange = load_exchange(&*state.storage).await?;
let fee_bps = body.fee_bps.unwrap_or(AmmPool::DEFAULT_FEE_BPS);
let response = match body.action.as_str() {
"add-liquidity" => {
let pool = exchange.get_or_create_pool(&body.currency_a, &body.currency_b, fee_bps);
match pool.add_liquidity(&provider, body.amount_a, body.amount_b, &mut ledger) {
Ok(issued) => {
let mut deposited = serde_json::Map::new();
deposited.insert(body.currency_a.clone(), body.amount_a.into());
deposited.insert(body.currency_b.clone(), body.amount_b.into());
serde_json::json!({
"action": "add-liquidity",
"shares": issued,
"deposited": deposited,
"total_shares": pool.total_shares,
"reserve_a": pool.reserve_a,
"reserve_b": pool.reserve_b,
})
}
Err(e) => return Ok(payment_error_response(e)),
}
}
"remove-liquidity" => {
let pool = match exchange.get_pool(&body.currency_a, &body.currency_b) {
Some(_) => exchange.get_or_create_pool(&body.currency_a, &body.currency_b, fee_bps),
None => {
return Ok(HttpResponse::BadRequest()
.json(serde_json::json!({ "error": "Pool has no liquidity" })))
}
};
match pool.remove_liquidity(&provider, body.shares, &mut ledger) {
Ok((got_a, got_b)) => {
let mut withdrawn = serde_json::Map::new();
withdrawn.insert(body.currency_a.clone(), got_a.into());
withdrawn.insert(body.currency_b.clone(), got_b.into());
serde_json::json!({
"action": "remove-liquidity",
"withdrawn": withdrawn,
"total_shares": pool.total_shares,
"reserve_a": pool.reserve_a,
"reserve_b": pool.reserve_b,
})
}
Err(e) => return Ok(payment_error_response(e)),
}
}
"swap" => {
let from = match body.from_currency.as_deref() {
Some(f) => f.to_string(),
None => {
return Ok(HttpResponse::BadRequest().json(
serde_json::json!({ "error": "Specify from_currency for a pool swap" }),
))
}
};
let pool = match exchange.get_pool(&body.currency_a, &body.currency_b) {
Some(_) => exchange.get_or_create_pool(&body.currency_a, &body.currency_b, fee_bps),
None => {
return Ok(HttpResponse::BadRequest()
.json(serde_json::json!({ "error": "Pool has no liquidity" })))
}
};
match pool.swap(&provider, &from, body.amount_in, &mut ledger) {
Ok(result) => serde_json::json!({
"action": "swap",
"amount_in": result.amount_in,
"amount_out": result.amount_out,
"fee": result.fee,
"new_balance_in": result.new_balance_in,
"new_balance_out": result.new_balance_out,
"reserve_a": pool.reserve_a,
"reserve_b": pool.reserve_b,
}),
Err(e) => return Ok(payment_error_response(e)),
}
}
other => {
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("Unknown action '{other}' (want swap | add-liquidity | remove-liquidity)")
})));
}
};
if let Err(e) = store.write_ledger(&ledger).await {
return Ok(payment_error_response(e));
}
save_exchange(&*state.storage, &exchange).await?;
Ok(HttpResponse::Ok()
.content_type("application/json")
.json(response))
}
fn mempool_client(state: &AppState) -> MempoolHttpClient {
match &state.mempool_url {
Some(url) => MempoolHttpClient::new(url.clone()),
None => MempoolHttpClient::from_env(),
}
}
fn transfer_proof_json(
state: &Mrc20State,
prev_state: &Mrc20State,
trail: &solid_pod_rs::mrc20::Mrc20Trail,
) -> serde_json::Value {
serde_json::json!({
"state": state,
"prevState": prev_state,
"anchor": anchor_proof_json(trail),
})
}
#[derive(Debug, Deserialize, Default)]
struct TokenMoveBody {
#[serde(default)]
ticker: Option<String>,
#[serde(default)]
amount: Option<u64>,
#[serde(default)]
sats: Option<u64>,
#[serde(default)]
tokens: Option<u64>,
#[serde(default)]
all: Option<bool>,
}
async fn execute_token_transfer(
state: &AppState,
ticker: &str,
buyer_pubkey: &str,
did: &str,
token_amount: u64,
sat_cost: u64,
) -> Result<(String, serde_json::Value, u64), HttpResponse> {
let storage = &state.storage;
let mut stored = match load_trail(storage, ticker).await {
Ok(Some(t)) => t,
Ok(None) => {
return Err(HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Token {ticker} not minted on this pod")
})));
}
Err(e) => return Err(payment_error_response(e)),
};
let mempool = mempool_client(state);
let public = stored.to_public();
let prev_state = match public.states.last() {
Some(s) => s.clone(),
None => {
return Err(HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Trail has no states"})));
}
};
let update = match transfer_token_with_key(
&public,
&stored.privkey,
None,
buyer_pubkey,
token_amount,
DEFAULT_FEE_SATS,
&mempool,
)
.await
{
Ok(u) => u,
Err(e) => {
return Err(HttpResponse::InternalServerError()
.json(serde_json::json!({"error": format!("Transfer failed: {e}")})));
}
};
let txid = match mempool.broadcast_tx(&update.tx.raw_hex).await {
Ok(t) => t,
Err(e) => {
return Err(HttpResponse::InternalServerError()
.json(serde_json::json!({"error": format!("Broadcast failed: {e}")})));
}
};
let mut appended = update.trail.clone();
appended.current_txid = txid.clone();
stored.merge_public(&appended);
stored.current_txid = txid.clone();
stored.current_vout = 0;
if let Err(e) = save_trail(storage, &stored).await {
return Err(payment_error_response(e));
}
let store = StoragePaymentStore::new(&**storage);
let mut ledger = match store.read_ledger().await {
Ok(l) => l,
Err(e) => return Err(payment_error_response(e)),
};
if let Err(e) = ledger.debit(did, sat_cost) {
return Err(payment_error_response(e));
}
if let Err(e) = store.write_ledger(&ledger).await {
return Err(payment_error_response(e));
}
let new_balance = ledger.get_balance(did);
let proof = transfer_proof_json(&update.state, &prev_state, &appended);
Ok((txid, proof, new_balance))
}
async fn handle_buy(
req: HttpRequest,
state: web::Data<AppState>,
body: Bytes,
) -> Result<HttpResponse, ActixError> {
let did = match require_did(&req).await {
Ok(d) => d,
Err(rsp) => return Ok(rsp),
};
let buyer_pubkey = match did.strip_prefix("did:nostr:") {
Some(pk) => pk.to_string(),
None => return Ok(HttpResponse::Unauthorized().json(serde_json::json!({
"error": "NIP-98 authentication required"
}))),
};
let token_cfg = match &state.pay_config.token {
Some(t) => t.clone(),
None => {
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": "Primary market not configured (no pay-token set)"
})));
}
};
let pay_token = token_cfg.ticker.clone();
let pay_rate = token_cfg.rate.max(1);
let req_body: TokenMoveBody = serde_json::from_slice(&body).unwrap_or_default();
let ticker = req_body.ticker.clone().unwrap_or_else(|| pay_token.clone());
if ticker != pay_token {
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("This pod only sells {pay_token}")
})));
}
let (token_amount, sat_cost) = if let Some(a) = req_body.amount {
(a, a * pay_rate)
} else if let Some(s) = req_body.sats {
(s / pay_rate, s)
} else {
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": "Specify amount (tokens to buy) or sats (sats to spend)",
"rate": pay_rate, "unit": "sat/token"
})));
};
if token_amount == 0 {
return Ok(HttpResponse::BadRequest()
.json(serde_json::json!({"error": "Amount must be positive"})));
}
let store = StoragePaymentStore::new(&*state.storage);
let balance = match store.read_ledger().await {
Ok(l) => l.get_balance(&did),
Err(e) => return Ok(payment_error_response(e)),
};
if balance < sat_cost {
return Ok(HttpResponse::PaymentRequired().json(serde_json::json!({
"error": "Insufficient sat balance",
"balance": balance, "cost": sat_cost, "rate": pay_rate,
"deposit": "/pay/.deposit"
})));
}
match execute_token_transfer(&state, &pay_token, &buyer_pubkey, &did, token_amount, sat_cost)
.await
{
Ok((txid, proof, new_balance)) => Ok(HttpResponse::Ok().json(serde_json::json!({
"bought": token_amount, "ticker": pay_token, "cost": sat_cost,
"rate": pay_rate, "balance": new_balance, "unit": "sat",
"txid": txid, "proof": proof
}))),
Err(rsp) => Ok(rsp),
}
}
async fn handle_withdraw(
req: HttpRequest,
state: web::Data<AppState>,
body: Bytes,
) -> Result<HttpResponse, ActixError> {
let did = match require_did(&req).await {
Ok(d) => d,
Err(rsp) => return Ok(rsp),
};
let user_pubkey = match did.strip_prefix("did:nostr:") {
Some(pk) => pk.to_string(),
None => return Ok(HttpResponse::Unauthorized().json(serde_json::json!({
"error": "NIP-98 authentication required"
}))),
};
let token_cfg = match &state.pay_config.token {
Some(t) => t.clone(),
None => {
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": "Withdrawal not configured (no pay-token set)"
})));
}
};
let pay_token = token_cfg.ticker.clone();
let pay_rate = token_cfg.rate.max(1);
let req_body: TokenMoveBody = serde_json::from_slice(&body).unwrap_or_default();
let store = StoragePaymentStore::new(&*state.storage);
let balance = match store.read_ledger().await {
Ok(l) => l.get_balance(&did),
Err(e) => return Ok(payment_error_response(e)),
};
let (token_amount, sat_cost) = if req_body.all.unwrap_or(false) {
(balance / pay_rate, balance)
} else if let Some(s) = req_body.sats {
(s / pay_rate, s)
} else if let Some(t) = req_body.tokens {
(t, t * pay_rate)
} else {
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": "Specify tokens, sats, or all: true",
"balance": balance, "rate": pay_rate, "unit": "sat/token"
})));
};
if token_amount == 0 {
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": "Nothing to withdraw", "balance": balance, "rate": pay_rate
})));
}
if balance < sat_cost {
return Ok(HttpResponse::PaymentRequired().json(serde_json::json!({
"error": "Insufficient balance", "balance": balance,
"cost": sat_cost, "rate": pay_rate
})));
}
match execute_token_transfer(&state, &pay_token, &user_pubkey, &did, token_amount, sat_cost)
.await
{
Ok((txid, proof, new_balance)) => Ok(HttpResponse::Ok().json(serde_json::json!({
"withdrawn": token_amount, "ticker": pay_token, "cost": sat_cost,
"rate": pay_rate, "balance": new_balance, "unit": "sat",
"txid": txid, "proof": proof
}))),
Err(rsp) => Ok(rsp),
}
}
#[derive(Debug, Deserialize)]
struct WithdrawSatsBody {
amount: u64,
#[serde(default)]
chain: Option<String>,
funding: String,
}
async fn handle_withdraw_sats(
req: HttpRequest,
state: web::Data<AppState>,
body: Bytes,
) -> Result<HttpResponse, ActixError> {
let did = match require_did(&req).await {
Ok(d) => d,
Err(rsp) => return Ok(rsp),
};
let req_body: WithdrawSatsBody = match serde_json::from_slice(&body) {
Ok(b) => b,
Err(_) => {
return Ok(HttpResponse::BadRequest()
.json(serde_json::json!({"error": "Invalid JSON body"})));
}
};
if req_body.amount == 0 {
return Ok(HttpResponse::BadRequest()
.json(serde_json::json!({"error": "Specify amount to withdraw"})));
}
let chain_id = req_body
.chain
.clone()
.or_else(|| state.pay_config.chains.first().map(|c| c.id.clone()))
.unwrap_or_else(|| "tbtc4".to_string());
if !state.pay_config.chains.is_empty()
&& !state.pay_config.chains.iter().any(|c| c.id == chain_id)
{
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("Chain '{chain_id}' not enabled")
})));
}
let store = StoragePaymentStore::new(&*state.storage);
let balance = match store.read_ledger().await {
Ok(l) => l.get_balance(&did),
Err(e) => return Ok(payment_error_response(e)),
};
if balance < req_body.amount {
return Ok(HttpResponse::PaymentRequired().json(serde_json::json!({
"error": "Insufficient balance", "balance": balance,
"requested": req_body.amount
})));
}
let funding = match parse_txo_voucher(&req_body.funding) {
Ok(v) => v,
Err(e) => return Ok(payment_error_response(e)),
};
let mempool = mempool_client(&state);
use solid_pod_rs::mrc20::MempoolLookup;
let funding_spk_hex = match mempool.tx(&funding.txid).await {
Ok(tx) => match tx.vout.get(funding.vout as usize).and_then(|o| o.scriptpubkey.clone()) {
Some(spk) => spk,
None => {
return Ok(HttpResponse::BadRequest()
.json(serde_json::json!({"error": "Funding output not found"})));
}
},
Err(e) => return Ok(payment_error_response(e)),
};
let voucher = match build_withdraw_voucher(&funding, &funding_spk_hex, req_body.amount, DEFAULT_FEE_SATS) {
Ok(v) => v,
Err(PaymentError::InvalidState(m)) if m.contains("funding") && m.contains("needed") => {
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": "Not enough funding-voucher value for withdrawal + fee",
"available": funding.amount, "needed": req_body.amount + DEFAULT_FEE_SATS
})));
}
Err(e) => return Ok(payment_error_response(e)),
};
let txid = match mempool.broadcast_tx(&voucher.tx.raw_hex).await {
Ok(t) => t,
Err(e) => {
return Ok(HttpResponse::InternalServerError()
.json(serde_json::json!({"error": format!("Broadcast failed: {e}")})));
}
};
let mut ledger = match store.read_ledger().await {
Ok(l) => l,
Err(e) => return Ok(payment_error_response(e)),
};
if let Err(e) = ledger.debit(&did, req_body.amount) {
return Ok(payment_error_response(e));
}
if let Err(e) = store.write_ledger(&ledger).await {
return Ok(payment_error_response(e));
}
let voucher_uri = format!(
"txo:{chain_id}:{txid}:0?amount={}&key={}",
req_body.amount, voucher.voucher_privkey_hex
);
Ok(HttpResponse::Ok().json(serde_json::json!({
"voucher": voucher_uri,
"amount": req_body.amount,
"chain": chain_id,
"txid": txid,
"balance": ledger.get_balance(&did),
})))
}
pub fn register(app: &mut web::ServiceConfig) {
app.route("/pay/.balance", web::get().to(handle_balance))
.route("/pay/.deposit", web::post().to(handle_deposit))
.route("/pay/.address", web::get().to(handle_address))
.route("/pay/.offers", web::get().to(handle_offers))
.route("/pay/.sell", web::post().to(handle_sell))
.route("/pay/.swap", web::post().to(handle_swap))
.route("/pay/.pool", web::get().to(handle_pool_get))
.route("/pay/.pool", web::post().to(handle_pool_post))
.route("/pay/.buy", web::post().to(handle_buy))
.route("/pay/.withdraw", web::post().to(handle_withdraw))
.route("/pay/.withdraw-sats", web::post().to(handle_withdraw_sats));
}