diff --git a/crates/sage-api/src/records/offer.rs b/crates/sage-api/src/records/offer.rs index f1fea37e..23224ba6 100644 --- a/crates/sage-api/src/records/offer.rs +++ b/crates/sage-api/src/records/offer.rs @@ -1,12 +1,15 @@ use serde::{Deserialize, Serialize}; use specta::Type; +use super::OfferSummary; + #[derive(Debug, Clone, Serialize, Deserialize, Type)] pub struct OfferRecord { pub offer_id: String, pub offer: String, pub status: OfferRecordStatus, pub creation_date: String, + pub summary: OfferSummary, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, Type)] diff --git a/crates/sage-database/src/offers.rs b/crates/sage-database/src/offers.rs index c499f229..e0e9e834 100644 --- a/crates/sage-database/src/offers.rs +++ b/crates/sage-database/src/offers.rs @@ -3,7 +3,10 @@ use std::time::{SystemTime, UNIX_EPOCH}; use chia::protocol::Bytes32; use sqlx::SqliteExecutor; -use crate::{into_row, Database, DatabaseTx, OfferRow, OfferSql, Result}; +use crate::{ + into_row, Database, DatabaseTx, OfferCatRow, OfferCatSql, OfferNftRow, OfferNftSql, OfferRow, + OfferSql, OfferXchRow, OfferXchSql, Result, +}; impl Database { pub async fn active_offers(&self) -> Result> { @@ -17,6 +20,18 @@ impl Database { pub async fn delete_offer(&self, offer_id: Bytes32) -> Result<()> { delete_offer(&self.pool, offer_id).await } + + pub async fn offer_xch(&self, offer_id: Bytes32) -> Result> { + offer_xch(&self.pool, offer_id).await + } + + pub async fn offer_nfts(&self, offer_id: Bytes32) -> Result> { + offer_nfts(&self.pool, offer_id).await + } + + pub async fn offer_cats(&self, offer_id: Bytes32) -> Result> { + offer_cats(&self.pool, offer_id).await + } } impl DatabaseTx<'_> { @@ -24,8 +39,20 @@ impl DatabaseTx<'_> { insert_offer(&mut *self.tx, row).await } - pub async fn insert_offer_coin(&mut self, offer_id: Bytes32, coin_id: Bytes32) -> Result<()> { - insert_offer_coin(&mut *self.tx, offer_id, coin_id).await + pub async fn insert_offered_coin(&mut self, offer_id: Bytes32, coin_id: Bytes32) -> Result<()> { + insert_offered_coin(&mut *self.tx, offer_id, coin_id).await + } + + pub async fn insert_offer_xch(&mut self, row: OfferXchRow) -> Result<()> { + insert_offer_xch(&mut *self.tx, row).await + } + + pub async fn insert_offer_nft(&mut self, row: OfferNftRow) -> Result<()> { + insert_offer_nft(&mut *self.tx, row).await + } + + pub async fn insert_offer_cat(&mut self, row: OfferCatRow) -> Result<()> { + insert_offer_cat(&mut *self.tx, row).await } } @@ -41,18 +68,22 @@ async fn insert_offer(conn: impl SqliteExecutor<'_>, row: OfferRow) -> Result<() .to_be_bytes(); let timestamp = timestamp.as_ref(); + let fee = row.fee.to_be_bytes(); + let fee = fee.as_ref(); + sqlx::query!( " INSERT OR IGNORE INTO `offers` ( `offer_id`, `encoded_offer`, `expiration_height`, - `expiration_timestamp`, `status`, `inserted_timestamp` + `expiration_timestamp`, `fee`, `status`, `inserted_timestamp` ) - VALUES (?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?) ", offer_id, row.encoded_offer, row.expiration_height, expiration_timestamp, + fee, status, timestamp ) @@ -61,7 +92,7 @@ async fn insert_offer(conn: impl SqliteExecutor<'_>, row: OfferRow) -> Result<() Ok(()) } -async fn insert_offer_coin( +async fn insert_offered_coin( conn: impl SqliteExecutor<'_>, offer_id: Bytes32, coin_id: Bytes32, @@ -70,7 +101,7 @@ async fn insert_offer_coin( let coin_id = coin_id.as_ref(); sqlx::query!( - "INSERT OR IGNORE INTO `offer_coins` (`offer_id`, `coin_id`) VALUES (?, ?)", + "INSERT OR IGNORE INTO `offered_coins` (`offer_id`, `coin_id`) VALUES (?, ?)", offer_id, coin_id ) @@ -80,6 +111,91 @@ async fn insert_offer_coin( Ok(()) } +async fn insert_offer_xch(conn: impl SqliteExecutor<'_>, row: OfferXchRow) -> Result<()> { + let offer_id = row.offer_id.as_ref(); + let amount = row.amount.to_be_bytes(); + let amount = amount.as_ref(); + let royalty = row.royalty.to_be_bytes(); + let royalty = royalty.as_ref(); + + sqlx::query!( + " + INSERT OR IGNORE INTO `offer_xch` ( + `offer_id`, `requested`, `amount`, `royalty` + ) + VALUES (?, ?, ?, ?) + ", + offer_id, + row.requested, + amount, + royalty + ) + .execute(conn) + .await?; + + Ok(()) +} + +async fn insert_offer_nft(conn: impl SqliteExecutor<'_>, row: OfferNftRow) -> Result<()> { + let offer_id = row.offer_id.as_ref(); + let launcher_id = row.launcher_id.as_ref(); + let royalty_puzzle_hash = row.royalty_puzzle_hash.as_ref(); + + sqlx::query!( + " + INSERT OR IGNORE INTO `offer_nfts` ( + `offer_id`, `requested`, `launcher_id`, + `royalty_puzzle_hash`, `royalty_ten_thousandths`, + `name`, `thumbnail`, `thumbnail_mime_type` + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ", + offer_id, + row.requested, + launcher_id, + royalty_puzzle_hash, + row.royalty_ten_thousandths, + row.name, + row.thumbnail, + row.thumbnail_mime_type + ) + .execute(conn) + .await?; + + Ok(()) +} + +async fn insert_offer_cat(conn: impl SqliteExecutor<'_>, row: OfferCatRow) -> Result<()> { + let offer_id = row.offer_id.as_ref(); + let asset_id = row.asset_id.as_ref(); + let amount = row.amount.to_be_bytes(); + let amount = amount.as_ref(); + let royalty = row.royalty.to_be_bytes(); + let royalty = royalty.as_ref(); + + sqlx::query!( + " + INSERT OR IGNORE INTO `offer_cats` ( + `offer_id`, `requested`, `asset_id`, + `amount`, `royalty`, `name`, `ticker`, `icon` + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ", + offer_id, + row.requested, + asset_id, + amount, + royalty, + row.name, + row.ticker, + row.icon + ) + .execute(conn) + .await?; + + Ok(()) +} + async fn active_offers(conn: impl SqliteExecutor<'_>) -> Result> { sqlx::query_as!( OfferSql, @@ -113,3 +229,57 @@ pub async fn delete_offer(conn: impl SqliteExecutor<'_>, offer_id: Bytes32) -> R Ok(()) } + +pub async fn offer_xch( + conn: impl SqliteExecutor<'_>, + offer_id: Bytes32, +) -> Result> { + let offer_id = offer_id.as_ref(); + + sqlx::query_as!( + OfferXchSql, + "SELECT * FROM `offer_xch` WHERE `offer_id` = ?", + offer_id + ) + .fetch_all(conn) + .await? + .into_iter() + .map(into_row) + .collect() +} + +pub async fn offer_nfts( + conn: impl SqliteExecutor<'_>, + offer_id: Bytes32, +) -> Result> { + let offer_id = offer_id.as_ref(); + + sqlx::query_as!( + OfferNftSql, + "SELECT * FROM `offer_nfts` WHERE `offer_id` = ?", + offer_id + ) + .fetch_all(conn) + .await? + .into_iter() + .map(into_row) + .collect() +} + +pub async fn offer_cats( + conn: impl SqliteExecutor<'_>, + offer_id: Bytes32, +) -> Result> { + let offer_id = offer_id.as_ref(); + + sqlx::query_as!( + OfferCatSql, + "SELECT * FROM `offer_cats` WHERE `offer_id` = ?", + offer_id + ) + .fetch_all(conn) + .await? + .into_iter() + .map(into_row) + .collect() +} diff --git a/crates/sage-database/src/primitives/cats.rs b/crates/sage-database/src/primitives/cats.rs index 5f1319d9..dcd06928 100644 --- a/crates/sage-database/src/primitives/cats.rs +++ b/crates/sage-database/src/primitives/cats.rs @@ -228,12 +228,12 @@ async fn spendable_cat_coins( FROM `cat_coins` INDEXED BY `cat_asset_id` INNER JOIN `coin_states` AS cs ON `cat_coins`.`coin_id` = cs.`coin_id` LEFT JOIN `transaction_spends` ON cs.`coin_id` = `transaction_spends`.`coin_id` - LEFT JOIN `offer_coins` ON cs.`coin_id` = `offer_coins`.`coin_id` - LEFT JOIN `offers` ON `offer_coins`.`offer_id` = `offers`.`offer_id` + LEFT JOIN `offered_coins` ON cs.`coin_id` = `offered_coins`.`coin_id` + LEFT JOIN `offers` ON `offered_coins`.`offer_id` = `offers`.`offer_id` WHERE `cat_coins`.`asset_id` = ? AND cs.`spent_height` IS NULL AND `transaction_spends`.`coin_id` IS NULL - AND (`offer_coins`.`coin_id` IS NULL OR `offers`.`status` > 0) + AND (`offered_coins`.`coin_id` IS NULL OR `offers`.`status` > 0) AND cs.`transaction_id` IS NULL ", asset_id diff --git a/crates/sage-database/src/primitives/xch.rs b/crates/sage-database/src/primitives/xch.rs index 6c244b93..ab5e7238 100644 --- a/crates/sage-database/src/primitives/xch.rs +++ b/crates/sage-database/src/primitives/xch.rs @@ -65,11 +65,11 @@ async fn spendable_coins(conn: impl SqliteExecutor<'_>) -> Result> { SELECT `coin_states`.`parent_coin_id`, `coin_states`.`puzzle_hash`, `coin_states`.`amount` FROM `coin_states` INNER JOIN `p2_coins` ON `coin_states`.`coin_id` = `p2_coins`.`coin_id` LEFT JOIN `transaction_spends` ON `coin_states`.`coin_id` = `transaction_spends`.`coin_id` - LEFT JOIN `offer_coins` ON `coin_states`.`coin_id` = `offer_coins`.`coin_id` - LEFT JOIN `offers` ON `offer_coins`.`offer_id` = `offers`.`offer_id` + LEFT JOIN `offered_coins` ON `coin_states`.`coin_id` = `offered_coins`.`coin_id` + LEFT JOIN `offers` ON `offered_coins`.`offer_id` = `offers`.`offer_id` WHERE `coin_states`.`spent_height` IS NULL AND `transaction_spends`.`coin_id` IS NULL - AND (`offer_coins`.`coin_id` IS NULL OR `offers`.`status` > 0) + AND (`offered_coins`.`coin_id` IS NULL OR `offers`.`status` > 0) AND `coin_states`.`transaction_id` IS NULL " ) diff --git a/crates/sage-database/src/rows/offer.rs b/crates/sage-database/src/rows/offer.rs index 86586288..da86fe7c 100644 --- a/crates/sage-database/src/rows/offer.rs +++ b/crates/sage-database/src/rows/offer.rs @@ -9,6 +9,7 @@ pub(crate) struct OfferSql { pub encoded_offer: String, pub expiration_height: Option, pub expiration_timestamp: Option>, + pub fee: Vec, pub status: i64, pub inserted_timestamp: Vec, } @@ -19,6 +20,7 @@ pub struct OfferRow { pub encoded_offer: String, pub expiration_height: Option, pub expiration_timestamp: Option, + pub fee: u64, pub status: OfferStatus, pub inserted_timestamp: u64, } @@ -45,6 +47,7 @@ impl IntoRow for OfferSql { .as_deref() .map(to_u64) .transpose()?, + fee: to_u64(&self.fee)?, status: match self.status { 0 => OfferStatus::Active, 1 => OfferStatus::Completed, @@ -56,3 +59,111 @@ impl IntoRow for OfferSql { }) } } + +pub(crate) struct OfferXchSql { + pub offer_id: Vec, + pub requested: bool, + pub amount: Vec, + pub royalty: Vec, +} + +#[derive(Debug, Clone, Copy)] +pub struct OfferXchRow { + pub offer_id: Bytes32, + pub requested: bool, + pub amount: u64, + pub royalty: u64, +} + +impl IntoRow for OfferXchSql { + type Row = OfferXchRow; + + fn into_row(self) -> Result { + Ok(OfferXchRow { + offer_id: to_bytes32(&self.offer_id)?, + requested: self.requested, + amount: to_u64(&self.amount)?, + royalty: to_u64(&self.royalty)?, + }) + } +} + +pub(crate) struct OfferNftSql { + pub offer_id: Vec, + pub requested: bool, + pub launcher_id: Vec, + pub royalty_puzzle_hash: Vec, + pub royalty_ten_thousandths: i64, + pub name: Option, + pub thumbnail: Option>, + pub thumbnail_mime_type: Option, +} + +#[derive(Debug, Clone)] +pub struct OfferNftRow { + pub offer_id: Bytes32, + pub requested: bool, + pub launcher_id: Bytes32, + pub royalty_puzzle_hash: Bytes32, + pub royalty_ten_thousandths: u16, + pub name: Option, + pub thumbnail: Option>, + pub thumbnail_mime_type: Option, +} + +impl IntoRow for OfferNftSql { + type Row = OfferNftRow; + + fn into_row(self) -> Result { + Ok(OfferNftRow { + offer_id: to_bytes32(&self.offer_id)?, + requested: self.requested, + launcher_id: to_bytes32(&self.launcher_id)?, + royalty_puzzle_hash: to_bytes32(&self.royalty_puzzle_hash)?, + royalty_ten_thousandths: self.royalty_ten_thousandths.try_into()?, + name: self.name, + thumbnail: self.thumbnail, + thumbnail_mime_type: self.thumbnail_mime_type, + }) + } +} + +pub(crate) struct OfferCatSql { + pub offer_id: Vec, + pub requested: bool, + pub asset_id: Vec, + pub amount: Vec, + pub royalty: Vec, + pub name: Option, + pub ticker: Option, + pub icon: Option, +} + +#[derive(Debug, Clone)] +pub struct OfferCatRow { + pub offer_id: Bytes32, + pub requested: bool, + pub asset_id: Bytes32, + pub amount: u64, + pub royalty: u64, + pub name: Option, + pub ticker: Option, + pub icon: Option, +} + +impl IntoRow for OfferCatSql { + type Row = OfferCatRow; + + fn into_row(self) -> Result { + Ok(OfferCatRow { + offer_id: to_bytes32(&self.offer_id)?, + requested: self.requested, + asset_id: to_bytes32(&self.asset_id)?, + amount: to_u64(&self.amount)?, + royalty: to_u64(&self.royalty)?, + name: self.name, + ticker: self.ticker, + icon: self.icon, + }) + } +} diff --git a/crates/sage/src/endpoints/offers.rs b/crates/sage/src/endpoints/offers.rs index 512b9693..81ac122a 100644 --- a/crates/sage/src/endpoints/offers.rs +++ b/crates/sage/src/endpoints/offers.rs @@ -1,24 +1,37 @@ -use std::time::{SystemTime, UNIX_EPOCH}; +use std::{ + collections::HashMap, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; -use chia::protocol::SpendBundle; -use chia_wallet_sdk::{AggSigConstants, Offer}; +use base64::{prelude::BASE64_STANDARD, Engine}; +use bigdecimal::BigDecimal; +use chia::{ + clvm_traits::FromClvm, + protocol::{Bytes32, SpendBundle}, + puzzles::nft::NftMetadata, +}; +use chia_wallet_sdk::{encode_address, AggSigConstants, Offer, SpendContext}; use chrono::{Local, TimeZone}; use clvmr::Allocator; use indexmap::IndexMap; use sage_api::{ - CatAmount, DeleteOffer, DeleteOfferResponse, GetOffers, GetOffersResponse, ImportOffer, - ImportOfferResponse, MakeOffer, MakeOfferResponse, OfferRecord, OfferRecordStatus, TakeOffer, - TakeOfferResponse, ViewOffer, ViewOfferResponse, + Amount, CatAmount, DeleteOffer, DeleteOfferResponse, GetOffers, GetOffersResponse, ImportOffer, + ImportOfferResponse, MakeOffer, MakeOfferResponse, OfferAssets, OfferCat, OfferNft, + OfferRecord, OfferRecordStatus, OfferSummary, OfferXch, TakeOffer, TakeOfferResponse, + ViewOffer, ViewOfferResponse, }; -use sage_database::{OfferRow, OfferStatus}; +use sage_database::{OfferCatRow, OfferNftRow, OfferRow, OfferStatus, OfferXchRow}; use sage_wallet::{ - fetch_nft_offer_details, insert_transaction, MakerSide, SyncCommand, TakerSide, Transaction, + calculate_royalties, fetch_nft_offer_details, insert_transaction, lookup_from_uris_with_hash, + parse_locked_coins, parse_offer_payments, MakerSide, NftRoyaltyInfo, SyncCommand, TakerSide, + Transaction, }; use tracing::{debug, warn}; use crate::{ - json_bundle, lookup_coin_creation, offer_expiration, parse_asset_id, parse_cat_amount, - parse_genesis_challenge, parse_nft_id, ConfirmationInfo, Error, OfferExpiration, Result, Sage, + extract_nft_data, json_bundle, lookup_coin_creation, offer_expiration, parse_asset_id, + parse_cat_amount, parse_genesis_challenge, parse_nft_id, ConfirmationInfo, Error, + ExtractedNftData, Result, Sage, }; impl Sage { @@ -176,27 +189,218 @@ impl Sage { let mut allocator = Allocator::new(); let parsed_offer = offer.parse(&mut allocator)?; + let coin_ids: Vec = parsed_offer + .coin_spends + .iter() + .map(|cs| cs.coin.coin_id()) + .collect(); let status = if let Some(peer) = peer { let coin_creation = lookup_coin_creation( &peer, - parsed_offer - .coin_spends - .iter() - .map(|cs| cs.coin.coin_id()) - .collect(), + coin_ids.clone(), parse_genesis_challenge(self.network().genesis_challenge.clone())?, ) .await?; offer_expiration(&mut allocator, &parsed_offer, &coin_creation)? } else { warn!("No peers available to fetch coin creation information, so skipping for now"); - OfferExpiration { - expiration_height: None, - expiration_timestamp: None, - } + offer_expiration(&mut allocator, &parsed_offer, &HashMap::new())? }; + let maker = parse_locked_coins(&mut allocator, &parsed_offer)?; + let maker_amounts = maker.amounts(); + let mut builder = parsed_offer.take(); + let mut ctx = SpendContext::from(allocator); + let taker = parse_offer_payments(&mut ctx, &mut builder)?; + let taker_amounts = taker.amounts(); + + let maker_royalties = calculate_royalties( + &maker.amounts(), + &taker + .nfts + .values() + .map(|(nft, _payments)| NftRoyaltyInfo { + launcher_id: nft.launcher_id, + royalty_puzzle_hash: nft.royalty_puzzle_hash, + royalty_ten_thousandths: nft.royalty_ten_thousandths, + }) + .collect::>(), + )? + .amounts(); + + let taker_royalties = calculate_royalties( + &taker_amounts, + &maker + .nfts + .values() + .map(|nft| NftRoyaltyInfo { + launcher_id: nft.info.launcher_id, + royalty_puzzle_hash: nft.info.royalty_puzzle_hash, + royalty_ten_thousandths: nft.info.royalty_ten_thousandths, + }) + .collect::>(), + )? + .amounts(); + + let offer_id = spend_bundle.name(); + + let mut cat_rows = Vec::new(); + let mut nft_rows = Vec::new(); + + for (asset_id, amount) in maker_amounts.cats { + let info = wallet.db.cat(asset_id).await?; + let name = info.as_ref().and_then(|info| info.name.clone()); + let ticker = info.as_ref().and_then(|info| info.ticker.clone()); + let icon = info.as_ref().and_then(|info| info.icon.clone()); + + cat_rows.push(OfferCatRow { + offer_id, + requested: false, + asset_id, + amount, + name: name.clone(), + ticker: ticker.clone(), + icon: icon.clone(), + royalty: maker_royalties.cats.get(&asset_id).copied().unwrap_or(0), + }); + } + + for nft in maker.nfts.into_values() { + let info = if let Ok(metadata) = + NftMetadata::from_clvm(&ctx.allocator, nft.info.metadata.ptr()) + { + let mut confirmation_info = ConfirmationInfo::default(); + + if let Some(hash) = metadata.data_hash { + if let Some(data) = lookup_from_uris_with_hash( + metadata.data_uris.clone(), + Duration::from_secs(10), + Duration::from_secs(5), + hash, + ) + .await + { + confirmation_info.nft_data.insert(hash, data); + } + } + + if let Some(hash) = metadata.metadata_hash { + if let Some(data) = lookup_from_uris_with_hash( + metadata.metadata_uris.clone(), + Duration::from_secs(10), + Duration::from_secs(5), + hash, + ) + .await + { + confirmation_info.nft_data.insert(hash, data); + } + } + + extract_nft_data( + Some(&wallet.db), + Some(metadata), + &ConfirmationInfo::default(), + ) + .await? + } else { + ExtractedNftData::default() + }; + + nft_rows.push(OfferNftRow { + offer_id, + requested: false, + launcher_id: nft.info.launcher_id, + royalty_puzzle_hash: nft.info.royalty_puzzle_hash, + royalty_ten_thousandths: nft.info.royalty_ten_thousandths, + name: info.name, + thumbnail: info + .image_data + .map(|data| BASE64_STANDARD.decode(data)) + .transpose() + .ok() + .flatten(), + thumbnail_mime_type: info.image_mime_type, + }); + } + + for (asset_id, amount) in taker_amounts.cats { + let info = wallet.db.cat(asset_id).await?; + let name = info.as_ref().and_then(|info| info.name.clone()); + let ticker = info.as_ref().and_then(|info| info.ticker.clone()); + let icon = info.as_ref().and_then(|info| info.icon.clone()); + + cat_rows.push(OfferCatRow { + offer_id, + requested: true, + asset_id, + amount, + name: name.clone(), + ticker: ticker.clone(), + icon: icon.clone(), + royalty: taker_royalties.cats.get(&asset_id).copied().unwrap_or(0), + }); + } + + for (nft, _) in taker.nfts.into_values() { + let info = + if let Ok(metadata) = NftMetadata::from_clvm(&ctx.allocator, nft.metadata.ptr()) { + let mut confirmation_info = ConfirmationInfo::default(); + + if let Some(hash) = metadata.data_hash { + if let Some(data) = lookup_from_uris_with_hash( + metadata.data_uris.clone(), + Duration::from_secs(10), + Duration::from_secs(5), + hash, + ) + .await + { + confirmation_info.nft_data.insert(hash, data); + } + } + + if let Some(hash) = metadata.metadata_hash { + if let Some(data) = lookup_from_uris_with_hash( + metadata.metadata_uris.clone(), + Duration::from_secs(10), + Duration::from_secs(5), + hash, + ) + .await + { + confirmation_info.nft_data.insert(hash, data); + } + } + + extract_nft_data( + Some(&wallet.db), + Some(metadata), + &ConfirmationInfo::default(), + ) + .await? + } else { + ExtractedNftData::default() + }; + + nft_rows.push(OfferNftRow { + offer_id, + requested: true, + launcher_id: nft.launcher_id, + royalty_puzzle_hash: nft.royalty_puzzle_hash, + royalty_ten_thousandths: nft.royalty_ten_thousandths, + name: info.name, + thumbnail: info + .image_data + .map(|data| BASE64_STANDARD.decode(data)) + .transpose() + .ok() + .flatten(), + thumbnail_mime_type: info.image_mime_type, + }); + } + let mut tx = wallet.db.tx().await?; let inserted_timestamp = SystemTime::now() @@ -204,21 +408,47 @@ impl Sage { .expect("system time is before the UNIX epoch") .as_secs(); - let offer_id = spend_bundle.name(); - tx.insert_offer(OfferRow { offer_id, encoded_offer: req.offer, expiration_height: status.expiration_height, expiration_timestamp: status.expiration_timestamp, + fee: maker.fee, status: OfferStatus::Active, inserted_timestamp, }) .await?; - for coin_state in parsed_offer.coin_spends { - tx.insert_offer_coin(offer_id, coin_state.coin.coin_id()) - .await?; + for coin_id in coin_ids { + tx.insert_offered_coin(offer_id, coin_id).await?; + } + + if maker_amounts.xch > 0 || maker_royalties.xch > 0 { + tx.insert_offer_xch(OfferXchRow { + offer_id, + requested: false, + amount: maker_amounts.xch, + royalty: maker_royalties.xch, + }) + .await?; + } + + if taker_amounts.xch > 0 || taker_royalties.xch > 0 { + tx.insert_offer_xch(OfferXchRow { + offer_id, + requested: true, + amount: taker_amounts.xch, + royalty: taker_royalties.xch, + }) + .await?; + } + + for row in cat_rows { + tx.insert_offer_cat(row).await?; + } + + for row in nft_rows { + tx.insert_offer_nft(row).await?; } tx.commit().await?; @@ -233,6 +463,72 @@ impl Sage { let mut records = Vec::new(); for offer in offers { + let xch = wallet.db.offer_xch(offer.offer_id).await?; + let cats = wallet.db.offer_cats(offer.offer_id).await?; + let nfts = wallet.db.offer_nfts(offer.offer_id).await?; + + let mut maker_xch_amount = 0u128; + let mut maker_xch_royalty = 0u128; + let mut taker_xch_amount = 0u128; + let mut taker_xch_royalty = 0u128; + + for xch in xch { + if xch.requested { + taker_xch_amount += xch.amount as u128; + taker_xch_royalty += xch.royalty as u128; + } else { + maker_xch_amount += xch.amount as u128; + maker_xch_royalty += xch.royalty as u128; + } + } + + let mut maker_cats = IndexMap::new(); + let mut taker_cats = IndexMap::new(); + + for cat in cats { + let asset_id = hex::encode(cat.asset_id); + + let record = OfferCat { + amount: Amount::from_mojos(cat.amount as u128, self.network().precision), + royalty: Amount::from_mojos(cat.royalty as u128, self.network().precision), + name: cat.name, + ticker: cat.ticker, + icon_url: cat.icon, + }; + + if cat.requested { + taker_cats.insert(asset_id, record); + } else { + maker_cats.insert(asset_id, record); + } + } + + let mut maker_nfts = IndexMap::new(); + let mut taker_nfts = IndexMap::new(); + + for nft in nfts { + let nft_id = encode_address(nft.launcher_id.into(), "nft")?; + + let record = OfferNft { + royalty_address: encode_address( + nft.royalty_puzzle_hash.into(), + &self.network().address_prefix, + )?, + royalty_percent: (BigDecimal::from(nft.royalty_ten_thousandths) + / BigDecimal::from(10_000)) + .to_string(), + name: nft.name, + image_data: nft.thumbnail.map(|data| BASE64_STANDARD.encode(data)), + image_mime_type: nft.thumbnail_mime_type, + }; + + if nft.requested { + taker_nfts.insert(nft_id, record); + } else { + maker_nfts.insert(nft_id, record); + } + } + records.push(OfferRecord { offer_id: hex::encode(offer.offer_id), offer: offer.encoded_offer, @@ -247,6 +543,31 @@ impl Sage { .unwrap() .format("%B %d, %Y %r") .to_string(), + summary: OfferSummary { + maker: OfferAssets { + xch: OfferXch { + amount: Amount::from_mojos(maker_xch_amount, self.network().precision), + royalty: Amount::from_mojos( + maker_xch_royalty, + self.network().precision, + ), + }, + cats: maker_cats, + nfts: maker_nfts, + }, + taker: OfferAssets { + xch: OfferXch { + amount: Amount::from_mojos(taker_xch_amount, self.network().precision), + royalty: Amount::from_mojos( + taker_xch_royalty, + self.network().precision, + ), + }, + cats: taker_cats, + nfts: taker_nfts, + }, + fee: Amount::from_mojos(offer.fee as u128, self.network().precision), + }, }); } diff --git a/crates/sage/src/utils/offer_summary.rs b/crates/sage/src/utils/offer_summary.rs index e628e53a..c18d00d6 100644 --- a/crates/sage/src/utils/offer_summary.rs +++ b/crates/sage/src/utils/offer_summary.rs @@ -12,7 +12,7 @@ use sage_wallet::{ use crate::{Result, Sage}; -use super::{extract_nft_data, ConfirmationInfo}; +use super::{extract_nft_data, ConfirmationInfo, ExtractedNftData}; impl Sage { pub(crate) async fn summarize_offer(&self, offer: Offer) -> Result { @@ -85,42 +85,41 @@ impl Sage { } for (launcher_id, nft) in locked_coins.nfts { - let metadata = NftMetadata::from_clvm(&ctx.allocator, nft.info.metadata.ptr())?; - - let mut confirmation_info = ConfirmationInfo::default(); - - if let Some(hash) = metadata.data_hash { - if let Some(data) = lookup_from_uris_with_hash( - metadata.data_uris.clone(), - Duration::from_secs(10), - Duration::from_secs(5), - hash, - ) - .await - { - confirmation_info.nft_data.insert(hash, data); + let info = if let Ok(metadata) = + NftMetadata::from_clvm(&ctx.allocator, nft.info.metadata.ptr()) + { + let mut confirmation_info = ConfirmationInfo::default(); + + if let Some(hash) = metadata.data_hash { + if let Some(data) = lookup_from_uris_with_hash( + metadata.data_uris.clone(), + Duration::from_secs(10), + Duration::from_secs(5), + hash, + ) + .await + { + confirmation_info.nft_data.insert(hash, data); + } } - } - - if let Some(hash) = metadata.metadata_hash { - if let Some(data) = lookup_from_uris_with_hash( - metadata.metadata_uris.clone(), - Duration::from_secs(10), - Duration::from_secs(5), - hash, - ) - .await - { - confirmation_info.nft_data.insert(hash, data); + + if let Some(hash) = metadata.metadata_hash { + if let Some(data) = lookup_from_uris_with_hash( + metadata.metadata_uris.clone(), + Duration::from_secs(10), + Duration::from_secs(5), + hash, + ) + .await + { + confirmation_info.nft_data.insert(hash, data); + } } - } - let info = extract_nft_data( - Some(&wallet.db), - Some(metadata), - &ConfirmationInfo::default(), - ) - .await?; + extract_nft_data(Some(&wallet.db), Some(metadata), &confirmation_info).await? + } else { + ExtractedNftData::default() + }; maker.nfts.insert( encode_address(launcher_id.to_bytes(), "nft")?, diff --git a/migrations/0002_offers.sql b/migrations/0002_offers.sql index 67c5c512..d26191a5 100644 --- a/migrations/0002_offers.sql +++ b/migrations/0002_offers.sql @@ -3,6 +3,7 @@ CREATE TABLE `offers` ( `encoded_offer` TEXT NOT NULL, `expiration_height` INTEGER, `expiration_timestamp` BLOB, + `fee` BLOB NOT NULL, `status` INTEGER NOT NULL, `inserted_timestamp` BLOB NOT NULL ); @@ -10,11 +11,52 @@ CREATE TABLE `offers` ( CREATE INDEX `offer_status` ON `offers` (`status`); CREATE INDEX `offer_timestamp` ON `offers` (`inserted_timestamp` DESC); -CREATE TABLE `offer_coins` ( +CREATE TABLE `offered_coins` ( `offer_id` BLOB NOT NULL, `coin_id` BLOB NOT NULL, PRIMARY KEY (`offer_id`, `coin_id`), FOREIGN KEY (`offer_id`) REFERENCES `offers`(`offer_id`) ON DELETE CASCADE ); -CREATE INDEX `offer_coin_id` ON `offer_coins` (`coin_id`); +CREATE INDEX `offer_coin_id` ON `offered_coins` (`coin_id`); + +CREATE TABLE `offer_xch` ( + `offer_id` BLOB NOT NULL, + `requested` BOOLEAN NOT NULL, + `amount` BLOB NOT NULL, + `royalty` BLOB NOT NULL, + PRIMARY KEY (`offer_id`, `requested`), + FOREIGN KEY (`offer_id`) REFERENCES `offers`(`offer_id`) ON DELETE CASCADE +); + +CREATE INDEX `xch_offer_id` ON `offer_xch` (`offer_id`); + +CREATE TABLE `offer_nfts` ( + `offer_id` BLOB NOT NULL, + `requested` BOOLEAN NOT NULL, + `launcher_id` BLOB NOT NULL, + `royalty_puzzle_hash` BLOB NOT NULL, + `royalty_ten_thousandths` INTEGER NOT NULL, + `name` TEXT, + `thumbnail` BLOB, + `thumbnail_mime_type` TEXT, + PRIMARY KEY (`offer_id`, `requested`), + FOREIGN KEY (`offer_id`) REFERENCES `offers`(`offer_id`) ON DELETE CASCADE +); + +CREATE INDEX `nft_offer_id` ON `offer_nfts` (`offer_id`); + +CREATE TABLE `offer_cats` ( + `offer_id` BLOB NOT NULL, + `requested` BOOLEAN NOT NULL, + `asset_id` BLOB NOT NULL, + `amount` BLOB NOT NULL, + `royalty` BLOB NOT NULL, + `name` TEXT, + `ticker` TEXT, + `icon` TEXT, + PRIMARY KEY (`offer_id`, `requested`), + FOREIGN KEY (`offer_id`) REFERENCES `offers`(`offer_id`) ON DELETE CASCADE +); + +CREATE INDEX `cat_offer_id` ON `offer_cats` (`offer_id`); diff --git a/src/bindings.ts b/src/bindings.ts index 731bbc9b..10d5ca9a 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -596,7 +596,7 @@ export type NftUriKind = "data" | "metadata" | "license" export type OfferAssets = { xch: OfferXch; cats: { [key in string]: OfferCat }; nfts: { [key in string]: OfferNft } } export type OfferCat = { amount: Amount; royalty: Amount; name: string | null; ticker: string | null; icon_url: string | null } export type OfferNft = { image_data: string | null; image_mime_type: string | null; name: string | null; royalty_percent: string; royalty_address: string } -export type OfferRecord = { offer_id: string; offer: string; status: OfferRecordStatus; creation_date: string } +export type OfferRecord = { offer_id: string; offer: string; status: OfferRecordStatus; creation_date: string; summary: OfferSummary } export type OfferRecordStatus = "active" | "completed" | "cancelled" | "expired" export type OfferSummary = { fee: Amount; maker: OfferAssets; taker: OfferAssets } export type OfferXch = { amount: Amount; royalty: Amount } diff --git a/src/pages/Offers.tsx b/src/pages/Offers.tsx index 3ab5d0f0..37fc9490 100644 --- a/src/pages/Offers.tsx +++ b/src/pages/Offers.tsx @@ -33,6 +33,8 @@ export function Offers() { const [dialogOpen, setDialogOpen] = useState(false); const [offers, setOffers] = useState([]); + console.log(offers); + const viewOffer = useCallback( (offer: string) => { if (offer.trim()) {