diff --git a/migration/src/m20230608_071249_init_db.rs b/migration/src/m20230608_071249_init_db.rs index 22ab407..388a49c 100644 --- a/migration/src/m20230608_071249_init_db.rs +++ b/migration/src/m20230608_071249_init_db.rs @@ -19,7 +19,7 @@ impl MigrationTrait for Migration { .primary_key(), ) .col(ColumnDef::new(Txo::Txid).string().not_null()) - .col(ColumnDef::new(Txo::Vout).unsigned().not_null()) + .col(ColumnDef::new(Txo::Vout).big_unsigned().not_null()) .col(ColumnDef::new(Txo::BtcAmount).string().not_null()) .col(ColumnDef::new(Txo::Spent).boolean().not_null()) .to_owned(), @@ -37,6 +37,29 @@ impl MigrationTrait for Migration { ) .await?; + manager + .create_table( + Table::create() + .table(Media::Table) + .if_not_exists() + .col( + ColumnDef::new(Media::Idx) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col( + ColumnDef::new(Media::Digest) + .string() + .not_null() + .unique_key(), + ) + .col(ColumnDef::new(Media::Mime).string().not_null()) + .to_owned(), + ) + .await?; + manager .create_table( Table::create() @@ -49,6 +72,7 @@ impl MigrationTrait for Migration { .auto_increment() .primary_key(), ) + .col(ColumnDef::new(Asset::MediaIdx).integer()) .col( ColumnDef::new(Asset::AssetId) .string() @@ -63,6 +87,14 @@ impl MigrationTrait for Migration { .col(ColumnDef::new(Asset::Precision).tiny_unsigned().not_null()) .col(ColumnDef::new(Asset::Ticker).string()) .col(ColumnDef::new(Asset::Timestamp).big_unsigned().not_null()) + .foreign_key( + ForeignKey::create() + .name("fk-asset-media") + .from(Asset::Table, Asset::MediaIdx) + .to(Media::Table, Media::Idx) + .on_delete(ForeignKeyAction::Restrict) + .on_update(ForeignKeyAction::Cascade), + ) .to_owned(), ) .await?; @@ -214,7 +246,7 @@ impl MigrationTrait for Migration { .col(ColumnDef::new(Transfer::RecipientType).tiny_unsigned()) .col(ColumnDef::new(Transfer::RecipientID).string()) .col(ColumnDef::new(Transfer::Ack).boolean()) - .col(ColumnDef::new(Transfer::Vout).unsigned()) + .col(ColumnDef::new(Transfer::Vout).big_unsigned()) .foreign_key( ForeignKey::create() .name("fk-transfer-assettransfer") @@ -329,6 +361,72 @@ impl MigrationTrait for Migration { ) .await?; + manager + .create_table( + Table::create() + .table(Token::Table) + .if_not_exists() + .col( + ColumnDef::new(Token::Idx) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(Token::AssetIdx).integer().not_null()) + .col(ColumnDef::new(Token::Index).big_unsigned().not_null()) + .col(ColumnDef::new(Token::Ticker).string()) + .col(ColumnDef::new(Token::Name).string()) + .col(ColumnDef::new(Token::Details).string()) + .col(ColumnDef::new(Token::EmbeddedMedia).boolean().not_null()) + .col(ColumnDef::new(Token::Reserves).boolean().not_null()) + .foreign_key( + ForeignKey::create() + .name("fk-token-asset") + .from(Token::Table, Token::AssetIdx) + .to(Asset::Table, Asset::Idx) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(TokenMedia::Table) + .if_not_exists() + .col( + ColumnDef::new(TokenMedia::Idx) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(TokenMedia::TokenIdx).integer().not_null()) + .col(ColumnDef::new(TokenMedia::MediaIdx).integer().not_null()) + .col(ColumnDef::new(TokenMedia::AttachmentId).tiny_unsigned()) + .foreign_key( + ForeignKey::create() + .name("fk-tokenmedia-token") + .from(TokenMedia::Table, TokenMedia::TokenIdx) + .to(Token::Table, Token::Idx) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk-tokenmedia-media") + .from(TokenMedia::Table, TokenMedia::MediaIdx) + .to(Media::Table, Media::Idx) + .on_delete(ForeignKeyAction::Restrict) + .on_update(ForeignKeyAction::Restrict), + ) + .to_owned(), + ) + .await?; + manager .create_table( Table::create() @@ -427,6 +525,18 @@ impl MigrationTrait for Migration { ) .await?; + manager + .drop_table(Table::drop().table(Token::Table).to_owned()) + .await?; + + manager + .drop_table(Table::drop().table(Media::Table).to_owned()) + .await?; + + manager + .drop_table(Table::drop().table(TokenMedia::Table).to_owned()) + .await?; + manager .drop_table(Table::drop().table(WalletTransaction::Table).to_owned()) .await?; @@ -451,6 +561,7 @@ pub enum Txo { pub enum Asset { Table, Idx, + MediaIdx, AssetId, Schema, AddedAt, @@ -523,6 +634,36 @@ pub enum TransferTransportEndpoint { Used, } +#[derive(DeriveIden)] +enum Token { + Table, + Idx, + AssetIdx, + Index, + Ticker, + Name, + Details, + EmbeddedMedia, + Reserves, +} + +#[derive(DeriveIden)] +enum Media { + Table, + Idx, + Digest, + Mime, +} + +#[derive(DeriveIden)] +enum TokenMedia { + Table, + Idx, + TokenIdx, + MediaIdx, + AttachmentId, +} + #[derive(DeriveIden)] enum WalletTransaction { Table, diff --git a/rgb-lib-ffi/Cargo.lock b/rgb-lib-ffi/Cargo.lock index 1b573a4..0c44555 100644 --- a/rgb-lib-ffi/Cargo.lock +++ b/rgb-lib-ffi/Cargo.lock @@ -3424,9 +3424,9 @@ dependencies = [ [[package]] name = "sea-orm" -version = "0.12.4" +version = "0.12.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14d17105eb8049488d2528580ecc3f0912ab177d600f10e8e292d6994870ba6a" +checksum = "e8e2dd2f8a2d129c1632ec45dcfc15c44cc3d8b969adc8ac58c5f011ca7aecee" dependencies = [ "async-stream", "async-trait", @@ -3483,9 +3483,9 @@ dependencies = [ [[package]] name = "sea-orm-migration" -version = "0.12.4" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a340d727bafe3d817b55f920498cc469e8664e8b654017d2ec93a31aed40b70f" +checksum = "9d45937e5d4869a0dcf0222bb264df564c077cbe9b312265f3717401d023a633" dependencies = [ "async-trait", "clap", @@ -3500,9 +3500,9 @@ dependencies = [ [[package]] name = "sea-query" -version = "0.30.2" +version = "0.30.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb3e6bba153bb198646c8762c48414942a38db27d142e44735a133cabddcc820" +checksum = "41558fa9bb5f4d73952dac0b9d9c2ce23966493fc9ee0008037b01d709838a68" dependencies = [ "bigdecimal", "chrono", diff --git a/rgb-lib-ffi/src/lib.rs b/rgb-lib-ffi/src/lib.rs index 705e0ff..37f38a5 100644 --- a/rgb-lib-ffi/src/lib.rs +++ b/rgb-lib-ffi/src/lib.rs @@ -11,18 +11,21 @@ type AssetCFA = rgb_lib::wallet::AssetCFA; type AssetIface = rgb_lib::wallet::AssetIface; type AssetNIA = rgb_lib::wallet::AssetNIA; type AssetSchema = rgb_lib::AssetSchema; +type AssetUDA = rgb_lib::wallet::AssetUDA; type Assets = rgb_lib::wallet::Assets; type Balance = rgb_lib::wallet::Balance; type BitcoinNetwork = rgb_lib::BitcoinNetwork; type BlockTime = rgb_lib::wallet::BlockTime; type BtcBalance = rgb_lib::wallet::BtcBalance; type DatabaseType = rgb_lib::wallet::DatabaseType; +type EmbeddedMedia = rgb_lib::wallet::EmbeddedMedia; type InvoiceData = rgb_lib::wallet::InvoiceData; type Keys = rgb_lib::keys::Keys; type Media = rgb_lib::wallet::Media; type Metadata = rgb_lib::wallet::Metadata; type Online = rgb_lib::wallet::Online; type Outpoint = rgb_lib::wallet::Outpoint; +type ProofOfReserves = rgb_lib::wallet::ProofOfReserves; type ReceiveData = rgb_lib::wallet::ReceiveData; type RecipientData = rgb_lib::wallet::RecipientData; type RefreshFilter = rgb_lib::wallet::RefreshFilter; @@ -34,6 +37,8 @@ type RgbLibInvoice = rgb_lib::wallet::Invoice; type RgbLibRecipient = rgb_lib::wallet::Recipient; type RgbLibTransportEndpoint = rgb_lib::wallet::TransportEndpoint; type RgbLibWallet = rgb_lib::wallet::Wallet; +type Token = rgb_lib::wallet::Token; +type TokenLight = rgb_lib::wallet::TokenLight; type Transaction = rgb_lib::wallet::Transaction; type TransactionType = rgb_lib::wallet::TransactionType; type Transfer = rgb_lib::wallet::Transfer; @@ -316,6 +321,27 @@ impl Wallet { .issue_asset_nia(online, ticker, name, precision, amounts) } + fn issue_asset_uda( + &self, + online: Online, + ticker: String, + name: String, + details: Option, + precision: u8, + media_file_path: Option, + attachments_file_paths: Vec, + ) -> Result { + self._get_wallet().issue_asset_uda( + online, + ticker, + name, + details, + precision, + media_file_path, + attachments_file_paths, + ) + } + fn issue_asset_cfa( &self, online: Online, diff --git a/rgb-lib-ffi/src/rgb-lib.udl b/rgb-lib-ffi/src/rgb-lib.udl index 8cb9297..5cb7f9d 100644 --- a/rgb-lib-ffi/src/rgb-lib.udl +++ b/rgb-lib-ffi/src/rgb-lib.udl @@ -32,6 +32,7 @@ interface RgbLibError { InvalidAddress(string details); InvalidAmountZero(); InvalidAssetID(string asset_id); + InvalidAttachments(string details); InvalidBitcoinKeys(); InvalidBitcoinNetwork(string network); InvalidBlindedUTXO(string details); @@ -72,11 +73,13 @@ interface RgbLibError { enum AssetIface { "RGB20", + "RGB21", "RGB25", }; enum AssetSchema { "Nia", + "Uda", "Cfa", }; @@ -99,6 +102,31 @@ dictionary AssetNIA { Media? media; }; +dictionary TokenLight { + u32 index; + string? ticker; + string? name; + string? details; + boolean embedded_media; + Media? media; + record attachments; + boolean reserves; +}; + +dictionary AssetUDA { + string asset_id; + AssetIface asset_iface; + string ticker; + string name; + string? details; + u8 precision; + u64 issued_supply; + i64 timestamp; + i64 added_at; + Balance balance; + TokenLight? token; +}; + dictionary AssetCFA { string asset_id; AssetIface asset_iface; @@ -114,6 +142,7 @@ dictionary AssetCFA { dictionary Assets { sequence? nia; + sequence? uda; sequence? cfa; }; @@ -189,6 +218,27 @@ dictionary Keys { string xpub_fingerprint; }; +dictionary EmbeddedMedia { + string mime; + sequence data; +}; + +dictionary ProofOfReserves { + Outpoint utxo; + sequence proof; +}; + +dictionary Token { + u32 index; + string? ticker; + string? name; + string? details; + EmbeddedMedia? embedded_media; + Media? media; + record attachments; + ProofOfReserves? reserves; +}; + dictionary Metadata { AssetIface asset_iface; AssetSchema asset_schema; @@ -198,6 +248,7 @@ dictionary Metadata { u8 precision; string? ticker; string? details; + Token? token; }; dictionary Online { @@ -381,6 +432,11 @@ interface Wallet { Online online, string ticker, string name, u8 precision, sequence amounts); + [Throws=RgbLibError] + AssetUDA issue_asset_uda( + Online online, string ticker, string name, string? details, u8 precision, + string? media_file_path, sequence attachments_file_paths); + [Throws=RgbLibError] AssetCFA issue_asset_cfa( Online online, string name, string? details, u8 precision, diff --git a/src/api/proxy.rs b/src/api/proxy.rs index e3ab88c..ff74a21 100644 --- a/src/api/proxy.rs +++ b/src/api/proxy.rs @@ -2,8 +2,7 @@ use amplify::s; use reqwest::blocking::{multipart, Client}; use reqwest::header::CONTENT_TYPE; use serde::{Deserialize, Serialize}; - -use std::path::PathBuf; +use std::path::Path; use crate::error::{Error, InternalError}; @@ -103,20 +102,20 @@ pub trait Proxy { ack: bool, ) -> Result, Error>; - fn post_consignment( + fn post_consignment>( self, url: &str, recipient_id: String, - consignment_path: PathBuf, + consignment_path: P, txid: String, vout: Option, ) -> Result, Error>; - fn post_media( + fn post_media>( self, url: &str, attachment_id: String, - media_path: PathBuf, + media_path: P, ) -> Result, Error>; } @@ -210,11 +209,11 @@ impl Proxy for Client { .map_err(InternalError::from)?) } - fn post_consignment( + fn post_consignment>( self, url: &str, recipient_id: String, - consignment_path: PathBuf, + consignment_path: P, txid: String, vout: Option, ) -> Result, Error> { @@ -243,11 +242,11 @@ impl Proxy for Client { .map_err(InternalError::from)?) } - fn post_media( + fn post_media>( self, url: &str, attachment_id: String, - media_path: PathBuf, + media_path: P, ) -> Result, Error> { let form = multipart::Form::new() .text("method", "media.post") diff --git a/src/database/entities/asset.rs b/src/database/entities/asset.rs index 2693533..2d56b3f 100644 --- a/src/database/entities/asset.rs +++ b/src/database/entities/asset.rs @@ -16,6 +16,7 @@ impl EntityName for Entity { #[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub idx: i32, + pub media_idx: Option, pub asset_id: String, pub schema: AssetSchema, pub added_at: i64, @@ -30,6 +31,7 @@ pub struct Model { #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] pub enum Column { Idx, + MediaIdx, AssetId, Schema, AddedAt, @@ -56,6 +58,8 @@ impl PrimaryKeyTrait for PrimaryKey { #[derive(Copy, Clone, Debug, EnumIter)] pub enum Relation { AssetTransfer, + Media, + Token, } impl ColumnTrait for Column { @@ -63,6 +67,7 @@ impl ColumnTrait for Column { fn def(&self) -> ColumnDef { match self { Self::Idx => ColumnType::Integer.def(), + Self::MediaIdx => ColumnType::Integer.def().null(), Self::AssetId => ColumnType::String(None).def().unique(), Self::Schema => ColumnType::SmallInteger.def(), Self::AddedAt => ColumnType::BigInteger.def(), @@ -80,6 +85,11 @@ impl RelationTrait for Relation { fn def(&self) -> RelationDef { match self { Self::AssetTransfer => Entity::has_many(super::asset_transfer::Entity).into(), + Self::Media => Entity::belongs_to(super::media::Entity) + .from(Column::MediaIdx) + .to(super::media::Column::Idx) + .into(), + Self::Token => Entity::has_many(super::token::Entity).into(), } } } @@ -90,4 +100,16 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::Media.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Token.def() + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/src/database/entities/asset_media.rs b/src/database/entities/asset_media.rs new file mode 100644 index 0000000..633940c --- /dev/null +++ b/src/database/entities/asset_media.rs @@ -0,0 +1,84 @@ +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.6 + +use sea_orm::entity::prelude::*; + +#[derive(Copy, Clone, Default, Debug, DeriveEntity)] +pub struct Entity; + +impl EntityName for Entity { + fn table_name(&self) -> &str { + "asset_media" + } +} + +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] +pub struct Model { + pub idx: i32, + pub asset_idx: i32, + pub media_idx: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] +pub enum Column { + Idx, + AssetIdx, + MediaIdx, +} + +#[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] +pub enum PrimaryKey { + Idx, +} + +impl PrimaryKeyTrait for PrimaryKey { + type ValueType = i32; + fn auto_increment() -> bool { + true + } +} + +#[derive(Copy, Clone, Debug, EnumIter)] +pub enum Relation { + Asset, + Media, +} + +impl ColumnTrait for Column { + type EntityName = Entity; + fn def(&self) -> ColumnDef { + match self { + Self::Idx => ColumnType::Integer.def(), + Self::AssetIdx => ColumnType::Integer.def(), + Self::MediaIdx => ColumnType::Integer.def(), + } + } +} + +impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + match self { + Self::Asset => Entity::belongs_to(super::asset::Entity) + .from(Column::AssetIdx) + .to(super::asset::Column::Idx) + .into(), + Self::Media => Entity::belongs_to(super::media::Entity) + .from(Column::MediaIdx) + .to(super::media::Column::Idx) + .into(), + } + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Asset.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Media.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/database/entities/media.rs b/src/database/entities/media.rs new file mode 100644 index 0000000..bbb34c4 --- /dev/null +++ b/src/database/entities/media.rs @@ -0,0 +1,78 @@ +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.6 + +use sea_orm::entity::prelude::*; + +#[derive(Copy, Clone, Default, Debug, DeriveEntity)] +pub struct Entity; + +impl EntityName for Entity { + fn table_name(&self) -> &str { + "media" + } +} + +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] +pub struct Model { + pub idx: i32, + pub digest: String, + pub mime: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] +pub enum Column { + Idx, + Digest, + Mime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] +pub enum PrimaryKey { + Idx, +} + +impl PrimaryKeyTrait for PrimaryKey { + type ValueType = i32; + fn auto_increment() -> bool { + true + } +} + +#[derive(Copy, Clone, Debug, EnumIter)] +pub enum Relation { + Asset, + TokenMedia, +} + +impl ColumnTrait for Column { + type EntityName = Entity; + fn def(&self) -> ColumnDef { + match self { + Self::Idx => ColumnType::Integer.def(), + Self::Digest => ColumnType::String(None).def().unique(), + Self::Mime => ColumnType::String(None).def(), + } + } +} + +impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + match self { + Self::Asset => Entity::has_many(super::asset::Entity).into(), + Self::TokenMedia => Entity::has_many(super::token_media::Entity).into(), + } + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Asset.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::TokenMedia.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/database/entities/mod.rs b/src/database/entities/mod.rs index a77bd45..dce8457 100644 --- a/src/database/entities/mod.rs +++ b/src/database/entities/mod.rs @@ -7,6 +7,9 @@ pub mod asset_transfer; pub mod backup_info; pub mod batch_transfer; pub mod coloring; +pub mod media; +pub mod token; +pub mod token_media; pub mod transfer; pub mod transfer_transport_endpoint; pub mod transport_endpoint; diff --git a/src/database/entities/prelude.rs b/src/database/entities/prelude.rs index 77caf29..5f5065c 100644 --- a/src/database/entities/prelude.rs +++ b/src/database/entities/prelude.rs @@ -5,6 +5,9 @@ pub use super::asset_transfer::Entity as AssetTransfer; pub use super::backup_info::Entity as BackupInfo; pub use super::batch_transfer::Entity as BatchTransfer; pub use super::coloring::Entity as Coloring; +pub use super::media::Entity as Media; +pub use super::token::Entity as Token; +pub use super::token_media::Entity as TokenMedia; pub use super::transfer::Entity as Transfer; pub use super::transfer_transport_endpoint::Entity as TransferTransportEndpoint; pub use super::transport_endpoint::Entity as TransportEndpoint; diff --git a/src/database/entities/token.rs b/src/database/entities/token.rs new file mode 100644 index 0000000..def2c88 --- /dev/null +++ b/src/database/entities/token.rs @@ -0,0 +1,96 @@ +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.6 + +use sea_orm::entity::prelude::*; + +#[derive(Copy, Clone, Default, Debug, DeriveEntity)] +pub struct Entity; + +impl EntityName for Entity { + fn table_name(&self) -> &str { + "token" + } +} + +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] +pub struct Model { + pub idx: i32, + pub asset_idx: i32, + pub index: u32, + pub ticker: Option, + pub name: Option, + pub details: Option, + pub embedded_media: bool, + pub reserves: bool, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] +pub enum Column { + Idx, + AssetIdx, + Index, + Ticker, + Name, + Details, + EmbeddedMedia, + Reserves, +} + +#[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] +pub enum PrimaryKey { + Idx, +} + +impl PrimaryKeyTrait for PrimaryKey { + type ValueType = i32; + fn auto_increment() -> bool { + true + } +} + +#[derive(Copy, Clone, Debug, EnumIter)] +pub enum Relation { + Asset, + TokenMedia, +} + +impl ColumnTrait for Column { + type EntityName = Entity; + fn def(&self) -> ColumnDef { + match self { + Self::Idx => ColumnType::Integer.def(), + Self::AssetIdx => ColumnType::Integer.def(), + Self::Index => ColumnType::BigInteger.def(), + Self::Ticker => ColumnType::String(None).def().null(), + Self::Name => ColumnType::String(None).def().null(), + Self::Details => ColumnType::String(None).def().null(), + Self::EmbeddedMedia => ColumnType::Boolean.def(), + Self::Reserves => ColumnType::Boolean.def(), + } + } +} + +impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + match self { + Self::Asset => Entity::belongs_to(super::asset::Entity) + .from(Column::AssetIdx) + .to(super::asset::Column::Idx) + .into(), + Self::TokenMedia => Entity::has_many(super::token_media::Entity).into(), + } + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Asset.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::TokenMedia.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/database/entities/token_media.rs b/src/database/entities/token_media.rs new file mode 100644 index 0000000..7048b26 --- /dev/null +++ b/src/database/entities/token_media.rs @@ -0,0 +1,87 @@ +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.6 + +use sea_orm::entity::prelude::*; + +#[derive(Copy, Clone, Default, Debug, DeriveEntity)] +pub struct Entity; + +impl EntityName for Entity { + fn table_name(&self) -> &str { + "token_media" + } +} + +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] +pub struct Model { + pub idx: i32, + pub token_idx: i32, + pub media_idx: i32, + pub attachment_id: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] +pub enum Column { + Idx, + TokenIdx, + MediaIdx, + AttachmentId, +} + +#[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] +pub enum PrimaryKey { + Idx, +} + +impl PrimaryKeyTrait for PrimaryKey { + type ValueType = i32; + fn auto_increment() -> bool { + true + } +} + +#[derive(Copy, Clone, Debug, EnumIter)] +pub enum Relation { + Media, + Token, +} + +impl ColumnTrait for Column { + type EntityName = Entity; + fn def(&self) -> ColumnDef { + match self { + Self::Idx => ColumnType::Integer.def(), + Self::TokenIdx => ColumnType::Integer.def(), + Self::MediaIdx => ColumnType::Integer.def(), + Self::AttachmentId => ColumnType::SmallInteger.def().null(), + } + } +} + +impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + match self { + Self::Media => Entity::belongs_to(super::media::Entity) + .from(Column::MediaIdx) + .to(super::media::Column::Idx) + .into(), + Self::Token => Entity::belongs_to(super::token::Entity) + .from(Column::TokenIdx) + .to(super::token::Column::Idx) + .into(), + } + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Media.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Token.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/database/entities/transfer.rs b/src/database/entities/transfer.rs index b873bf9..5d27907 100644 --- a/src/database/entities/transfer.rs +++ b/src/database/entities/transfer.rs @@ -66,7 +66,7 @@ impl ColumnTrait for Column { Self::RecipientType => ColumnType::SmallInteger.def().null(), Self::RecipientId => ColumnType::String(None).def().null(), Self::Ack => ColumnType::Boolean.def().null(), - Self::Vout => ColumnType::Integer.def().null(), + Self::Vout => ColumnType::BigInteger.def().null(), } } } diff --git a/src/database/entities/txo.rs b/src/database/entities/txo.rs index fae0f28..b9f6475 100644 --- a/src/database/entities/txo.rs +++ b/src/database/entities/txo.rs @@ -52,7 +52,7 @@ impl ColumnTrait for Column { match self { Self::Idx => ColumnType::Integer.def(), Self::Txid => ColumnType::String(None).def(), - Self::Vout => ColumnType::Integer.def(), + Self::Vout => ColumnType::BigInteger.def(), Self::BtcAmount => ColumnType::String(None).def(), Self::Spent => ColumnType::Boolean.def(), } diff --git a/src/database/enums.rs b/src/database/enums.rs index 8857fcb..3ddcf92 100644 --- a/src/database/enums.rs +++ b/src/database/enums.rs @@ -2,7 +2,7 @@ use sea_orm::{ActiveValue, DeriveActiveEnum, EnumIter, IntoActiveValue}; use serde::{Deserialize, Serialize}; use crate::{ - wallet::{SCHEMA_ID_CFA, SCHEMA_ID_NIA}, + wallet::{NUM_KNOWN_SCHEMAS, SCHEMA_ID_CFA, SCHEMA_ID_NIA, SCHEMA_ID_UDA}, Error, }; @@ -15,16 +15,22 @@ pub enum AssetSchema { /// NIA schema #[sea_orm(num_value = 1)] Nia = 1, - /// CFA schema + /// UDA schema #[sea_orm(num_value = 2)] - Cfa = 2, + Uda = 2, + /// CFA schema + #[sea_orm(num_value = 3)] + Cfa = 3, } impl AssetSchema { + pub(crate) const VALUES: [Self; NUM_KNOWN_SCHEMAS] = [Self::Nia, Self::Uda, Self::Cfa]; + /// Get the AssetSchema given a schema ID. pub fn from_schema_id(schema_id: String) -> Result { Ok(match &schema_id[..] { SCHEMA_ID_NIA => AssetSchema::Nia, + SCHEMA_ID_UDA => AssetSchema::Uda, SCHEMA_ID_CFA => AssetSchema::Cfa, _ => return Err(Error::UnknownRgbSchema { schema_id }), }) diff --git a/src/database/mod.rs b/src/database/mod.rs index 95c7e85..1980e64 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -23,6 +23,9 @@ use crate::database::entities::batch_transfer::{ }; use entities::asset::{ActiveModel as DbAssetActMod, Model as DbAsset}; use entities::coloring::{ActiveModel as DbColoringActMod, Model as DbColoring}; +use entities::media::{ActiveModel as DbMediaActMod, Model as DbMedia}; +use entities::token::{ActiveModel as DbTokenActMod, Model as DbToken}; +use entities::token_media::{ActiveModel as DbTokenMediaActMod, Model as DbTokenMedia}; use entities::transfer::{ActiveModel as DbTransferActMod, Model as DbTransfer}; use entities::transfer_transport_endpoint::{ ActiveModel as DbTransferTransportEndpointActMod, Model as DbTransferTransportEndpoint, @@ -35,8 +38,8 @@ use entities::wallet_transaction::{ ActiveModel as DbWalletTransactionActMod, Model as DbWalletTransaction, }; use entities::{ - asset, asset_transfer, backup_info, batch_transfer, coloring, transfer, - transfer_transport_endpoint, transport_endpoint, txo, wallet_transaction, + asset, asset_transfer, backup_info, batch_transfer, coloring, media, token, token_media, + transfer, transfer_transport_endpoint, transport_endpoint, txo, wallet_transaction, }; use self::enums::{ColoringType, RecipientType, TransferStatus, TransportType}; @@ -305,6 +308,24 @@ impl RgbLibDatabase { Ok(res.last_insert_id) } + pub(crate) fn set_media(&self, media: DbMediaActMod) -> Result { + let res = block_on(media::Entity::insert(media).exec(self.get_connection()))?; + Ok(res.last_insert_id) + } + + pub(crate) fn set_token(&self, token: DbTokenActMod) -> Result { + let res = block_on(token::Entity::insert(token).exec(self.get_connection()))?; + Ok(res.last_insert_id) + } + + pub(crate) fn set_token_media( + &self, + token_media: DbTokenMediaActMod, + ) -> Result { + let res = block_on(token_media::Entity::insert(token_media).exec(self.get_connection()))?; + Ok(res.last_insert_id) + } + pub(crate) fn set_transport_endpoint( &self, transport_endpoint: DbTransportEndpointActMod, @@ -426,12 +447,39 @@ impl RgbLibDatabase { Ok(()) } + pub(crate) fn get_asset(&self, asset_id: String) -> Result, InternalError> { + Ok(block_on( + asset::Entity::find() + .filter(asset::Column::AssetId.eq(asset_id.clone())) + .one(self.get_connection()), + )?) + } + pub(crate) fn get_backup_info(&self) -> Result, InternalError> { Ok(block_on( backup_info::Entity::find().one(self.get_connection()), )?) } + pub(crate) fn get_media(&self, media_idx: i32) -> Result, InternalError> { + Ok(block_on( + media::Entity::find() + .filter(media::Column::Idx.eq(media_idx)) + .one(self.get_connection()), + )?) + } + + pub(crate) fn get_media_by_digest( + &self, + digest: String, + ) -> Result, InternalError> { + Ok(block_on( + media::Entity::find() + .filter(media::Column::Digest.eq(digest)) + .one(self.get_connection()), + )?) + } + pub(crate) fn get_transport_endpoint( &self, endpoint: String, @@ -474,6 +522,20 @@ impl RgbLibDatabase { )?) } + pub(crate) fn iter_media(&self) -> Result, InternalError> { + Ok(block_on(media::Entity::find().all(self.get_connection()))?) + } + + pub(crate) fn iter_token_medias(&self) -> Result, InternalError> { + Ok(block_on( + token_media::Entity::find().all(self.get_connection()), + )?) + } + + pub(crate) fn iter_tokens(&self) -> Result, InternalError> { + Ok(block_on(token::Entity::find().all(self.get_connection()))?) + } + pub(crate) fn iter_transfers(&self) -> Result, InternalError> { Ok(block_on( transfer::Entity::find().all(self.get_connection()), @@ -667,13 +729,7 @@ impl RgbLibDatabase { } pub(crate) fn check_asset_exists(&self, asset_id: String) -> Result { - match block_on( - asset::Entity::find() - .filter(asset::Column::AssetId.eq(asset_id.clone())) - .one(self.get_connection()), - ) - .map_err(InternalError::from)? - { + match self.get_asset(asset_id.clone())? { Some(a) => Ok(a), None => Err(Error::AssetNotFound { asset_id }), } diff --git a/src/error.rs b/src/error.rs index 5de2e51..93aa0c7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -142,6 +142,13 @@ pub enum Error { asset_id: String, }, + /// The provided attachments are invalid + #[error("Invalid attachments: {details}")] + InvalidAttachments { + /// Error details + details: String, + }, + /// Keys derived from the provided data do not match #[error("Invalid bitcoin keys")] InvalidBitcoinKeys, diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 9962360..72fd0ca 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2,7 +2,8 @@ //! //! This module defines the [`Wallet`] structure and all its related data. -use amplify::{bmap, none, s, ByteArray}; +use amplify::confinement::Confined; +use amplify::{bmap, none, s, ByteArray, Wrapper}; use base64::{engine::general_purpose, Engine as _}; use bdk::bitcoin::bip32::ExtendedPubKey; use bdk::bitcoin::secp256k1::Secp256k1; @@ -38,10 +39,13 @@ use rgb::BlockchainResolver; use rgb_core::validation::Validity; use rgb_core::{Assign, Operation, Opout, SecretSeal, Transition}; use rgb_lib_migration::{Migrator, MigratorTrait}; -use rgb_schemata::{cfa_rgb25, cfa_schema, nia_rgb20, nia_schema}; +use rgb_schemata::{cfa_rgb25, cfa_schema, nia_rgb20, nia_schema, uda_rgb21, uda_schema}; use rgbstd::containers::{Bindle, BuilderSeal, Transfer as RgbTransfer}; use rgbstd::contract::{ContractId, GenesisSeal, GraphSeal}; -use rgbstd::interface::{rgb20, rgb25, ContractBuilder, ContractIface, Rgb20, Rgb25, TypedState}; +use rgbstd::interface::rgb21::{Allocation, OwnedFraction, TokenData, TokenIndex}; +use rgbstd::interface::{ + rgb20, rgb21, rgb25, ContractBuilder, ContractIface, Rgb20, Rgb21, Rgb25, TypedState, +}; use rgbstd::stl::{ Amount, AssetNaming, Attachment, ContractData, Details, DivisibleAssetSpec, MediaType, Name, Precision, RicardianContract, Ticker, Timestamp, @@ -78,6 +82,11 @@ use crate::database::entities::batch_transfer::{ ActiveModel as DbBatchTransferActMod, Model as DbBatchTransfer, }; use crate::database::entities::coloring::{ActiveModel as DbColoringActMod, Model as DbColoring}; +use crate::database::entities::media::{ActiveModel as DbMediaActMod, Model as DbMedia}; +use crate::database::entities::token::{ActiveModel as DbTokenActMod, Model as DbToken}; +use crate::database::entities::token_media::{ + ActiveModel as DbTokenMediaActMod, Model as DbTokenMedia, +}; use crate::database::entities::transfer::{ActiveModel as DbTransferActMod, Model as DbTransfer}; use crate::database::entities::transfer_transport_endpoint::{ ActiveModel as DbTransferTransportEndpointActMod, Model as DbTransferTransportEndpoint, @@ -108,25 +117,26 @@ pub(crate) const KEYCHAIN_RGB_OPRET: u8 = 9; pub(crate) const KEYCHAIN_RGB_TAPRET: u8 = 10; pub(crate) const KEYCHAIN_BTC: u8 = 1; -const ASSETS_DIR: &str = "assets"; +const MEDIA_DIR: &str = "media_files"; const TRANSFER_DIR: &str = "transfers"; + const TRANSFER_DATA_FILE: &str = "transfer_data.txt"; const SIGNED_PSBT_FILE: &str = "signed.psbt"; const CONSIGNMENT_FILE: &str = "consignment_out"; const CONSIGNMENT_RCV_FILE: &str = "rcv_compose.rgbc"; -const MEDIA_FNAME: &str = "media"; -const MIME_FNAME: &str = "mime"; const MIN_BTC_REQUIRED: u64 = 2000; const OPRET_VBYTES: f32 = 43.0; -const NUM_KNOWN_SCHEMAS: usize = 2; +pub(crate) const NUM_KNOWN_SCHEMAS: usize = 3; const UTXO_SIZE: u32 = 1000; const UTXO_NUM: u8 = 5; -const MAX_TRANSPORT_ENDPOINTS: u8 = 3; +const MAX_TRANSPORT_ENDPOINTS: usize = 3; + +const MAX_ATTACHMENTS: usize = 20; const MIN_FEE_RATE: f32 = 1.0; const MAX_FEE_RATE: f32 = 1000.0; @@ -141,6 +151,8 @@ const PROXY_PROTOCOL_VERSION: &str = "0.2"; pub(crate) const SCHEMA_ID_NIA: &str = "urn:lnp-bp:sc:BEiLYE-am9WhTW1-oK8cpvw4-FEMtzMrf-mKocuGZn-qWK6YF#ginger-parking-nirvana"; +pub(crate) const SCHEMA_ID_UDA: &str = + "urn:lnp-bp:sc:BWLbE1-u8rCxFfp-SeihsWzb-QTycb6SJ-Y8wDFaXy-9BE2gz#raymond-horse-final"; pub(crate) const SCHEMA_ID_CFA: &str = "urn:lnp-bp:sc:4nfgJ2-jkeTRQuG-uTet6NSW-Fy1sFTU8-qqrN2uY2-j6S5rv#ravioli-justin-brave"; @@ -149,6 +161,8 @@ pub(crate) const SCHEMA_ID_CFA: &str = pub enum AssetIface { /// RGB20 interface RGB20, + /// RGB21 interface + RGB21, /// RGB25 interface RGB25, } @@ -162,23 +176,28 @@ impl AssetIface { &self, wallet: &Wallet, asset: &DbAsset, - assets_dir: PathBuf, + token: Option, transfers: Option>, asset_transfers: Option>, batch_transfers: Option>, colorings: Option>, txos: Option>, + medias: Option>, ) -> Result { - let mut media = None; - let asset_dir = assets_dir.join(asset.asset_id.clone()); - if asset_dir.is_dir() { - for fp in fs::read_dir(asset_dir)? { - let fpath = fp?.path(); - let file_path = fpath.join(MEDIA_FNAME).to_string_lossy().to_string(); - let mime = fs::read_to_string(fpath.join(MIME_FNAME))?; - media = Some(Media { file_path, mime }); + let media = match &self { + AssetIface::RGB20 | AssetIface::RGB25 => { + let medias = if let Some(m) = medias { + m + } else { + wallet.database.iter_media()? + }; + medias + .iter() + .find(|m| Some(m.idx) == asset.media_idx) + .map(|m| Media::from_db_media(m, wallet._media_dir())) } - } + AssetIface::RGB21 => None, + }; let balance = wallet.database.get_asset_balance( asset.asset_id.clone(), transfers, @@ -202,6 +221,19 @@ impl AssetIface { balance, media, }), + AssetIface::RGB21 => AssetType::AssetUDA(AssetUDA { + asset_id: asset.asset_id.clone(), + asset_iface: self.clone(), + details: asset.details.clone(), + ticker: asset.ticker.clone().unwrap(), + name: asset.name.clone(), + precision: asset.precision, + issued_supply, + timestamp: asset.timestamp, + added_at: asset.added_at, + balance, + token, + }), AssetIface::RGB25 => AssetType::AssetCFA(AssetCFA { asset_id: asset.asset_id.clone(), asset_iface: self.clone(), @@ -222,6 +254,7 @@ impl From for AssetIface { fn from(x: AssetSchema) -> AssetIface { match x { AssetSchema::Nia => AssetIface::RGB20, + AssetSchema::Uda => AssetIface::RGB21, AssetSchema::Cfa => AssetIface::RGB25, } } @@ -250,6 +283,40 @@ pub struct Media { pub mime: String, } +impl Media { + fn get_digest(&self) -> String { + PathBuf::from(&self.file_path) + .file_name() + .unwrap() + .to_string_lossy() + .to_string() + } + + fn from_attachment>(attachment: &Attachment, media_dir: P) -> Self { + let file_path = media_dir + .as_ref() + .join(hex::encode(attachment.digest)) + .to_string_lossy() + .to_string(); + Self { + mime: attachment.ty.to_string(), + file_path, + } + } + + pub(crate) fn from_db_media>(db_media: &DbMedia, media_dir: P) -> Self { + let file_path = media_dir + .as_ref() + .join(db_media.digest.clone()) + .to_string_lossy() + .to_string(); + Self { + mime: db_media.mime.clone(), + file_path, + } + } +} + /// Metadata of an RGB asset. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] pub struct Metadata { @@ -269,6 +336,8 @@ pub struct Metadata { pub ticker: Option, /// Asset details pub details: Option, + /// Asset unique token + pub token: Option, } /// A Non-Inflatable Asset. @@ -302,22 +371,23 @@ impl AssetNIA { fn get_asset_details( wallet: &Wallet, asset: &DbAsset, - assets_dir: PathBuf, transfers: Option>, asset_transfers: Option>, batch_transfers: Option>, colorings: Option>, txos: Option>, + medias: Option>, ) -> Result { match AssetIface::RGB20.get_asset_details( wallet, asset, - assets_dir, + None, transfers, asset_transfers, batch_transfers, colorings, txos, + medias, )? { AssetType::AssetNIA(asset) => Ok(asset), _ => unreachable!("impossible"), @@ -325,6 +395,121 @@ impl AssetNIA { } } +/// Light version of an RGB21 [`Token`], with embedded_media and reserves as booleans. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +pub struct TokenLight { + /// Index of the token + pub index: u32, + /// Ticker of the token + pub ticker: Option, + /// Name of the token + pub name: Option, + /// Details of the token + pub details: Option, + /// Whether the token has an embedded media + pub embedded_media: bool, + /// Token primary media attachment + pub media: Option, + /// Token extra media attachments + pub attachments: HashMap, + /// Whether the token has proof of reserves + pub reserves: bool, +} + +/// A media embedded in the contract. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +pub struct EmbeddedMedia { + /// Mime of the embedded media + pub mime: String, + /// Bytes of the embedded media (max 16MB) + pub data: Vec, +} + +/// A proof of reserves. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +pub struct ProofOfReserves { + /// Proof of reserves UTXO + pub utxo: Outpoint, + /// Proof bytes + pub proof: Vec, +} + +/// An RGB21 token. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +pub struct Token { + /// Index of the token + pub index: u32, + /// Ticker of the token + pub ticker: Option, + /// Name of the token + pub name: Option, + /// Details of the token + pub details: Option, + /// Embedded media of the token + pub embedded_media: Option, + /// Token primary media attachment + pub media: Option, + /// Token extra media attachments + pub attachments: HashMap, + /// Proof of reserves of the token + pub reserves: Option, +} + +/// A Unique Digital Asset. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct AssetUDA { + /// ID of the asset + pub asset_id: String, + /// Asset interface type + pub asset_iface: AssetIface, + /// Ticker of the asset + pub ticker: String, + /// Name of the asset + pub name: String, + /// Details of the asset + pub details: Option, + /// Precision, also known as divisibility, of the asset + pub precision: u8, + /// Total issued amount + pub issued_supply: u64, + /// Timestamp of asset genesis + pub timestamp: i64, + /// Timestamp of asset import + pub added_at: i64, + /// Current balance of the asset + pub balance: Balance, + /// Asset unique token + pub token: Option, +} + +impl AssetUDA { + fn get_asset_details( + wallet: &Wallet, + asset: &DbAsset, + token: Option, + transfers: Option>, + asset_transfers: Option>, + batch_transfers: Option>, + colorings: Option>, + txos: Option>, + ) -> Result { + match AssetIface::RGB21.get_asset_details( + wallet, + asset, + token, + transfers, + asset_transfers, + batch_transfers, + colorings, + txos, + None, + )? { + AssetType::AssetUDA(asset) => Ok(asset), + _ => unreachable!("impossible"), + } + } +} + /// A Collectible Fungible Asset. #[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] pub struct AssetCFA { @@ -354,22 +539,23 @@ impl AssetCFA { fn get_asset_details( wallet: &Wallet, asset: &DbAsset, - assets_dir: PathBuf, transfers: Option>, asset_transfers: Option>, batch_transfers: Option>, colorings: Option>, txos: Option>, + medias: Option>, ) -> Result { match AssetIface::RGB25.get_asset_details( wallet, asset, - assets_dir, + None, transfers, asset_transfers, batch_transfers, colorings, txos, + medias, )? { AssetType::AssetCFA(asset) => Ok(asset), _ => unreachable!("impossible"), @@ -379,6 +565,7 @@ impl AssetCFA { enum AssetType { AssetNIA(AssetNIA), + AssetUDA(AssetUDA), AssetCFA(AssetCFA), } @@ -387,6 +574,8 @@ enum AssetType { pub struct Assets { /// List of NIA assets pub nia: Option>, + /// List of UDA assets + pub uda: Option>, /// List of CFA assets pub cfa: Option>, } @@ -676,7 +865,7 @@ struct OnlineData { } /// Bitcoin transaction outpoint. -#[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Deserialize, Serialize)] pub struct Outpoint { /// ID of the transaction pub txid: String, @@ -699,6 +888,15 @@ impl From for Outpoint { } } +impl From for Outpoint { + fn from(x: RgbOutpoint) -> Outpoint { + Outpoint { + txid: x.txid.to_string(), + vout: x.vout.into_u32(), + } + } +} + impl From for Outpoint { fn from(x: DbTxo) -> Outpoint { Outpoint { @@ -1058,6 +1256,7 @@ impl Wallet { } if !wallet_dir.exists() { fs::create_dir(wallet_dir.clone())?; + fs::create_dir(wallet_dir.join(MEDIA_DIR))?; } let logger = setup_logger(wallet_dir.clone(), None)?; info!(logger.clone(), "New wallet in '{:?}'", wallet_dir); @@ -1137,6 +1336,10 @@ impl Wallet { runtime.import_schema(nia_schema())?; runtime.import_iface_impl(nia_rgb20())?; + runtime.import_iface(rgb21())?; + runtime.import_schema(uda_schema())?; + runtime.import_iface_impl(uda_rgb21())?; + runtime.import_iface(rgb25())?; runtime.import_schema(cfa_schema())?; runtime.import_iface_impl(cfa_rgb25())?; @@ -1201,6 +1404,14 @@ impl Wallet { load_rgb_runtime(self.wallet_dir.clone(), self._bitcoin_network()) } + fn _media_dir(&self) -> PathBuf { + self.wallet_dir.join(MEDIA_DIR) + } + + fn _transfers_dir(&self) -> PathBuf { + self.wallet_dir.join(TRANSFER_DIR) + } + fn _check_genesis_hash( &self, bitcoin_network: &BitcoinNetwork, @@ -1245,7 +1456,7 @@ impl Wallet { details: s!("must provide at least a transport endpoint"), }); } - if transport_endpoints.len() > MAX_TRANSPORT_ENDPOINTS as usize { + if transport_endpoints.len() > MAX_TRANSPORT_ENDPOINTS { return Err(Error::InvalidTransportEndpoints { details: format!( "library supports at max {MAX_TRANSPORT_ENDPOINTS} transport endpoints" @@ -1536,6 +1747,7 @@ impl Wallet { let schema_id = genesis.schema_id.to_string(); Ok(match &schema_id[..] { SCHEMA_ID_NIA => AssetIface::RGB20, + SCHEMA_ID_UDA => AssetIface::RGB21, SCHEMA_ID_CFA => AssetIface::RGB25, _ => return Err(Error::UnknownRgbSchema { schema_id }), }) @@ -2491,18 +2703,9 @@ impl Wallet { asset_schema: &AssetSchema, contract_id: ContractId, ) -> Result { - Ok(match asset_schema { - AssetSchema::Nia => { - let iface_name = AssetIface::RGB20.to_typename(); - let iface = runtime.iface_by_name(&iface_name)?.clone(); - runtime.contract_iface(contract_id, iface.iface_id())? - } - AssetSchema::Cfa => { - let iface_name = AssetIface::RGB25.to_typename(); - let iface = runtime.iface_by_name(&iface_name)?.clone(); - runtime.contract_iface(contract_id, iface.iface_id())? - } - }) + let iface_name = AssetIface::from(*asset_schema).to_typename(); + let iface = runtime.iface_by_name(&iface_name)?.clone(); + Ok(runtime.contract_iface(contract_id, iface.iface_id())?) } fn _get_asset_timestamp(&self, contract: &ContractIface) -> Result { @@ -2520,10 +2723,168 @@ impl Wallet { Ok(timestamp) } + fn _get_uda_attachments(&self, contract: ContractIface) -> Result, Error> { + let mut uda_attachments = vec![]; + if let Ok(tokens) = contract.global("tokens") { + if tokens.is_empty() { + return Ok(uda_attachments); + } + let val = &tokens[0]; + + if let Some(attachment) = val + .unwrap_struct("media") + .unwrap_option() + .map(Attachment::from_strict_val_unchecked) + { + uda_attachments.push(attachment) + } + + match val.unwrap_struct("attachments") { + StrictVal::Map(fields) => { + for (_, attachment_struct) in fields { + let attachment = Attachment::from_strict_val_unchecked(attachment_struct); + uda_attachments.push(attachment) + } + } + _ => return Err(InternalError::Unexpected.into()), + }; + } + Ok(uda_attachments) + } + + fn _get_uda_token(&self, contract: ContractIface) -> Result, Error> { + if let Ok(tokens) = contract.global("tokens") { + if tokens.is_empty() { + return Ok(None); + } + let val = &tokens[0]; + + let index = val.unwrap_struct("index").unwrap_num().unwrap_uint(); + + let ticker = val + .unwrap_struct("ticker") + .unwrap_option() + .map(StrictVal::unwrap_string); + + let name = val + .unwrap_struct("name") + .unwrap_option() + .map(StrictVal::unwrap_string); + + let details = val + .unwrap_struct("details") + .unwrap_option() + .map(StrictVal::unwrap_string); + + let embedded_media = if let Some(preview) = val.unwrap_struct("preview").unwrap_option() + { + let ty = MediaType::from_strict_val_unchecked(preview.unwrap_struct("type")); + let mime = ty.to_string(); + let data = preview.unwrap_struct("data").unwrap_bytes().to_vec(); + Some(EmbeddedMedia { mime, data }) + } else { + None + }; + + let media_dir = self._media_dir(); + + let media = val + .unwrap_struct("media") + .unwrap_option() + .map(Attachment::from_strict_val_unchecked) + .map(|a| Media::from_attachment(&a, &media_dir)); + + let attachments = match val.unwrap_struct("attachments") { + StrictVal::Map(fields) => { + let mut map = HashMap::new(); + for (attachment_id, attachment_struct) in fields { + let attachment = Attachment::from_strict_val_unchecked(attachment_struct); + map.insert( + attachment_id.unwrap_num().unwrap_uint(), + Media::from_attachment(&attachment, &media_dir), + ); + } + map + } + _ => return Err(InternalError::Unexpected.into()), + }; + + let reserves: Option = + if let Some(reserves) = val.unwrap_struct("reserves").unwrap_option() { + let utxo = reserves.unwrap_struct("utxo"); + let txid = utxo.unwrap_struct("txid").unwrap_bytes(); + let txid: [u8; 32] = if txid.len() == 32 { + let mut array = [0; 32]; + array.copy_from_slice(txid); + array + } else { + return Err(InternalError::Unexpected.into()); + }; + let txid = RgbTxid::from_byte_array(txid); + let vout: u32 = utxo.unwrap_struct("vout").unwrap_num().unwrap_uint(); + let utxo = RgbOutpoint::new(txid, vout); + let proof = reserves.unwrap_struct("proof").unwrap_bytes().to_vec(); + Some(ProofOfReserves { + utxo: utxo.into(), + proof, + }) + } else { + None + }; + + Ok(Some(Token { + index, + ticker, + name, + details, + embedded_media, + media, + attachments, + reserves, + })) + } else { + Ok(None) + } + } + /// Return the [`Metadata`] for the RGB asset with the provided ID. pub fn get_asset_metadata(&self, asset_id: String) -> Result { info!(self.logger, "Getting metadata for asset '{}'...", asset_id); - let asset = self.database.check_asset_exists(asset_id)?; + let asset = self.database.check_asset_exists(asset_id.clone())?; + + let token = if matches!(asset.schema, AssetSchema::Uda) { + let medias = self.database.iter_media()?; + let tokens = self.database.iter_tokens()?; + let token_medias = self.database.iter_token_medias()?; + if let Some(token_light) = + self._get_asset_token(asset.idx, &medias, &tokens, &token_medias)? + { + let mut token = Token { + index: token_light.index, + ticker: token_light.ticker, + name: token_light.name, + details: token_light.details, + embedded_media: None, + media: token_light.media, + attachments: token_light.attachments, + reserves: None, + }; + if token_light.embedded_media || token_light.reserves { + let mut runtime = self._rgb_runtime()?; + let contract_id = ContractId::from_str(&asset_id).expect("invalid contract ID"); + let contract_iface = + self._get_contract_iface(&mut runtime, &asset.schema, contract_id)?; + let uda_token = self._get_uda_token(contract_iface)?.unwrap(); + token.embedded_media = uda_token.embedded_media; + token.reserves = uda_token.reserves; + } + Some(token) + } else { + None + } + } else { + None + }; Ok(Metadata { asset_iface: AssetIface::from(asset.schema), @@ -2534,6 +2895,7 @@ impl Wallet { precision: asset.precision, ticker: asset.ticker, details: asset.details, + token, }) } @@ -2589,6 +2951,16 @@ impl Wallet { }); } + let medias = self.database.iter_media()?; + let media_dir = self._media_dir(); + for media in medias { + if !media_dir.join(media.digest).exists() { + return Err(Error::Inconsistency { + details: s!("DB media do not match with the ones stored in media directory"), + }); + } + } + info!(self.logger, "Consistency check completed"); Ok(()) } @@ -2689,6 +3061,17 @@ impl Wallet { Ok(online) } + fn _check_details(&self, details: String) -> Result { + if details.is_empty() { + return Err(Error::InvalidDetails { + details: s!("ident must contain at least one character"), + }); + } + Details::from_str(&details).map_err(|e| Error::InvalidDetails { + details: e.to_string(), + }) + } + fn _check_name(&self, name: String) -> Result { Name::try_from(name).map_err(|e| Error::InvalidName { details: e.to_string(), @@ -2712,6 +3095,36 @@ impl Wallet { }) } + fn _get_or_insert_media(&self, digest: String, mime: String) -> Result { + Ok(match self.database.get_media_by_digest(digest.clone())? { + Some(media) => media.idx, + None => self.database.set_media(DbMediaActMod { + digest: ActiveValue::Set(digest), + mime: ActiveValue::Set(mime), + ..Default::default() + })?, + }) + } + + fn _save_token_media( + &self, + token_idx: i32, + digest: String, + mime: String, + attachment_id: Option, + ) -> Result<(), Error> { + let media_idx = self._get_or_insert_media(digest, mime)?; + + self.database.set_token_media(DbTokenMediaActMod { + token_idx: ActiveValue::Set(token_idx), + media_idx: ActiveValue::Set(media_idx), + attachment_id: ActiveValue::Set(attachment_id), + ..Default::default() + })?; + + Ok(()) + } + fn _add_asset_to_db( &self, asset_id: String, @@ -2723,10 +3136,12 @@ impl Wallet { precision: u8, ticker: Option, timestamp: i64, + media_idx: Option, ) -> Result { let added_at = added_at.unwrap_or_else(|| now().unix_timestamp()); let mut db_asset = DbAssetActMod { idx: ActiveValue::NotSet, + media_idx: ActiveValue::Set(media_idx), asset_id: ActiveValue::Set(asset_id), schema: ActiveValue::Set(*schema), added_at: ActiveValue::Set(added_at), @@ -2754,6 +3169,52 @@ impl Wallet { }) } + fn _file_details>( + &self, + original_file_path: P, + ) -> Result<(Attachment, Media), Error> { + if !original_file_path.as_ref().exists() { + return Err(Error::InvalidFilePath { + file_path: original_file_path.as_ref().to_string_lossy().to_string(), + }); + } + let file_bytes = fs::read(&original_file_path)?; + let file_hash: sha256::Hash = Sha256Hash::hash(&file_bytes[..]); + let digest = file_hash.to_byte_array(); + let mime = tree_magic::from_filepath(original_file_path.as_ref()); + let media_ty: &'static str = Box::leak(mime.clone().into_boxed_str()); + let media_type = MediaType::with(media_ty); + let file_path = self + ._media_dir() + .join(hex::encode(digest)) + .to_string_lossy() + .to_string(); + Ok(( + Attachment { + ty: media_type, + digest, + }, + Media { mime, file_path }, + )) + } + + fn _copy_media_and_save>( + &self, + original_file_path: P, + media: &Media, + ) -> Result { + fs::copy(original_file_path, media.clone().file_path)?; + self._get_or_insert_media(media.get_digest(), media.mime.clone()) + } + + fn _new_contract_data( + &self, + terms: RicardianContract, + media: Option, + ) -> ContractData { + ContractData { terms, media } + } + /// Issue a new RGB NIA asset with the provided `ticker`, `name`, `precision` and `amounts`, /// then return it. /// @@ -2772,7 +3233,7 @@ impl Wallet { ) -> Result { info!( self.logger, - "Issuing RGB20 asset with ticker '{}' name '{}' precision '{}' amounts '{:?}'...", + "Issuing NIA asset with ticker '{}' name '{}' precision '{}' amounts '{:?}'...", ticker, name, precision, @@ -2801,9 +3262,9 @@ impl Wallet { let created = Timestamp::from(created_at); let terms = RicardianContract::default(); #[cfg(test)] - let data = test::mock_contract_data(terms, None); + let data = test::mock_contract_data(self, terms, None); #[cfg(not(test))] - let data = ContractData { terms, media: None }; + let data = self._new_contract_data(terms, None); let spec = DivisibleAssetSpec { naming: AssetNaming { ticker: self._check_ticker(ticker.clone())?, @@ -2863,6 +3324,7 @@ impl Wallet { precision, Some(ticker), created_at, + None, )?; let batch_transfer = DbBatchTransferActMod { status: ActiveValue::Set(TransferStatus::Settled), @@ -2897,28 +3359,248 @@ impl Wallet { self.database.set_coloring(db_coloring)?; } - let asset = AssetNIA::get_asset_details( - self, - &asset, - self.wallet_dir.join(ASSETS_DIR), - None, + let asset = AssetNIA::get_asset_details(self, &asset, None, None, None, None, None, None)?; + + self.update_backup_info(false)?; + + info!(self.logger, "Issue asset NIA completed"); + Ok(asset) + } + + fn _new_token_data( + &self, + index: TokenIndex, + media_data: &Option<(Attachment, Media)>, + attachments: BTreeMap, + ) -> TokenData { + TokenData { + index, + media: media_data + .as_ref() + .map(|(attachment, _)| attachment.clone()), + attachments: Confined::try_from(attachments.clone()).unwrap(), + ..Default::default() + } + } + + /// Issue a new RGB UDA asset with the provided `ticker`, `name`, optional `details` and + /// `precision`, then return it. + /// + /// An optional `media_file_path` containing the path to a media file can be provided. Its hash + /// and mime type will be encoded in the contract. + /// + /// An optional `attachments_file_paths` containing paths to extra media files can be provided. + /// Their hash and mime type will be encoded in the contract. + pub fn issue_asset_uda( + &self, + online: Online, + ticker: String, + name: String, + details: Option, + precision: u8, + media_file_path: Option, + attachments_file_paths: Vec, + ) -> Result { + info!( + self.logger, + "Issuing UDA asset with ticker '{}' name '{}' precision '{}'...", + ticker, + name, + precision, + ); + self._check_online(online)?; + + if attachments_file_paths.len() > MAX_ATTACHMENTS { + return Err(Error::InvalidAttachments { + details: format!("no more than {MAX_ATTACHMENTS} attachments are supported"), + }); + } + + let settled = 1; + + let mut db_data = self.database.get_db_data(false)?; + self._handle_expired_transfers(&mut db_data)?; + + let mut unspents: Vec = self.database.get_rgb_allocations( + self.database.get_unspent_txos(db_data.txos)?, None, None, None, + )?; + unspents.retain(|u| { + !(u.rgb_allocations + .iter() + .any(|a| !a.incoming && a.status.waiting_counterparty())) + }); + + let created_at = now().unix_timestamp(); + let created = Timestamp::from(created_at); + let terms = RicardianContract::default(); + + let details_obj = if let Some(details) = &details { + Some(self._check_details(details.clone())?) + } else { + None + }; + let ticker_obj = self._check_ticker(ticker.clone())?; + let spec = DivisibleAssetSpec { + naming: AssetNaming { + ticker: ticker_obj.clone(), + name: self._check_name(name.clone())?, + details: details_obj, + }, + precision: self._check_precision(precision)?, + }; + + let issue_utxo = self._get_utxo(vec![], Some(unspents.clone()), false)?; + let outpoint = issue_utxo.outpoint().to_string(); + debug!(self.logger, "Issuing on UTXO: {issue_utxo:?}"); + + let seal = ExplicitSeal::::from_str(&format!("opret1st:{outpoint}")) + .map_err(InternalError::from)?; + let seal = GenesisSeal::from(seal); + + let index_int = 0; + let index = TokenIndex::from_inner(index_int); + + let fraction = OwnedFraction::from_inner(1); + let allocation = Allocation::with(index, fraction); + + let media_data = if let Some(media_file_path) = &media_file_path { + Some(self._file_details(media_file_path)?) + } else { + None + }; + + let mut attachments = BTreeMap::new(); + let mut media_attachments = HashMap::new(); + for (idx, attachment_file_path) in attachments_file_paths.iter().enumerate() { + let (attachment, media) = self._file_details(attachment_file_path)?; + attachments.insert(idx as u8, attachment); + media_attachments.insert(idx as u8, media); + } + + #[cfg(test)] + let token_data = test::mock_token_data(self, index, &media_data, attachments); + #[cfg(not(test))] + let token_data = self._new_token_data(index, &media_data, attachments); + + let token = TokenLight { + index: index_int, + media: media_data.as_ref().map(|(_, media)| media.clone()), + attachments: media_attachments.clone(), + ..Default::default() + }; + + let mut runtime = self._rgb_runtime()?; + let builder = ContractBuilder::with(rgb21(), uda_schema(), uda_rgb21()) + .map_err(InternalError::from)? + .set_chain(runtime.chain()) + .add_global_state("spec", spec) + .expect("invalid spec") + .add_global_state("created", created) + .expect("invalid created") + .add_global_state("terms", terms) + .expect("invalid terms") + .add_data_state("assetOwner", seal, allocation) + .expect("invalid global state data") + .add_global_state("tokens", token_data) + .expect("invalid tokens"); + + let contract = builder.issue_contract().expect("failure issuing contract"); + let asset_id = contract.contract_id().to_string(); + let validated_contract = contract + .clone() + .validate(&mut self._blockchain_resolver()?) + .expect("internal error: failed validating self-issued contract"); + runtime + .import_contract(validated_contract, &mut self._blockchain_resolver()?) + .expect("failure importing issued contract"); + + if let Some((_, media)) = &media_data { + self._copy_media_and_save(media_file_path.unwrap(), media)?; + } + for (idx, attachment_file_path) in attachments_file_paths.into_iter().enumerate() { + let media = media_attachments.get(&(idx as u8)).unwrap(); + self._copy_media_and_save(attachment_file_path, media)?; + } + + let asset = self._add_asset_to_db( + asset_id.clone(), + &AssetSchema::Uda, + Some(created_at), + details.clone(), + settled as u64, + name.clone(), + precision, + Some(ticker.clone()), + created_at, None, )?; + let batch_transfer = DbBatchTransferActMod { + status: ActiveValue::Set(TransferStatus::Settled), + expiration: ActiveValue::Set(None), + created_at: ActiveValue::Set(created_at), + min_confirmations: ActiveValue::Set(0), + ..Default::default() + }; + let batch_transfer_idx = self.database.set_batch_transfer(batch_transfer)?; + let asset_transfer = DbAssetTransferActMod { + user_driven: ActiveValue::Set(true), + batch_transfer_idx: ActiveValue::Set(batch_transfer_idx), + asset_id: ActiveValue::Set(Some(asset_id.clone())), + ..Default::default() + }; + let asset_transfer_idx = self.database.set_asset_transfer(asset_transfer)?; + let transfer = DbTransferActMod { + asset_transfer_idx: ActiveValue::Set(asset_transfer_idx), + amount: ActiveValue::Set(settled.to_string()), + incoming: ActiveValue::Set(true), + ..Default::default() + }; + self.database.set_transfer(transfer)?; + let db_coloring = DbColoringActMod { + txo_idx: ActiveValue::Set(issue_utxo.idx), + asset_transfer_idx: ActiveValue::Set(asset_transfer_idx), + coloring_type: ActiveValue::Set(ColoringType::Issue), + amount: ActiveValue::Set(settled.to_string()), + ..Default::default() + }; + self.database.set_coloring(db_coloring)?; + let db_token = DbTokenActMod { + asset_idx: ActiveValue::Set(asset.idx), + index: ActiveValue::Set(index_int), + embedded_media: ActiveValue::Set(false), + reserves: ActiveValue::Set(false), + ..Default::default() + }; + let token_idx = self.database.set_token(db_token)?; + if let Some((_, media)) = &media_data { + self._save_token_media(token_idx, media.get_digest(), media.mime.clone(), None)?; + } + for (attachment_id, media) in media_attachments { + self._save_token_media( + token_idx, + media.get_digest(), + media.mime.clone(), + Some(attachment_id), + )?; + } + + let asset = + AssetUDA::get_asset_details(self, &asset, Some(token), None, None, None, None, None)?; self.update_backup_info(false)?; - info!(self.logger, "Issue asset RGB20 completed"); + info!(self.logger, "Issue asset UDA completed"); Ok(asset) } /// Issue a new RGB CFA asset with the provided `name`, optional `details`, `precision` and /// `amounts`, then return it. /// - /// An optional `file_path` containing the path to a media file can be provided. Its hash (as - /// attachment ID) and mime type will be encoded in the contract. + /// An optional `file_path` containing the path to a media file can be provided. Its hash and + /// mime type will be encoded in the contract. /// /// At least 1 amount needs to be provided and the sum of all amounts cannot exceed the maximum /// `u64` value. @@ -2936,7 +3618,7 @@ impl Wallet { ) -> Result { info!( self.logger, - "Issuing RGB25 asset with name '{}' precision '{}' amounts '{:?}'...", + "Issuing CFA asset with name '{}' precision '{}' amounts '{:?}'...", name, precision, amounts @@ -2963,32 +3645,16 @@ impl Wallet { let created_at = now().unix_timestamp(); let created = Timestamp::from(created_at); let terms = RicardianContract::default(); - let (media, mime) = if let Some(fp) = &file_path { - let fpath = std::path::Path::new(fp); - if !fpath.exists() { - return Err(Error::InvalidFilePath { - file_path: fp.clone(), - }); - } - let file_bytes = std::fs::read(fp.clone())?; - let file_hash: sha256::Hash = Sha256Hash::hash(&file_bytes[..]); - let digest = file_hash.to_byte_array(); - let mime = tree_magic::from_filepath(fpath); - let media_ty: &'static str = Box::leak(mime.clone().into_boxed_str()); - let media_type = MediaType::with(media_ty); - ( - Some(Attachment { - ty: media_type, - digest, - }), - Some(mime), - ) + let media_data = if let Some(file_path) = &file_path { + Some(self._file_details(file_path)?) } else { - (None, None) + None }; let data = ContractData { terms, - media: media.clone(), + media: media_data + .as_ref() + .map(|(attachment, _)| attachment.clone()), }; let precision_state = self._check_precision(precision)?; let name_state = self._check_name(name.clone())?; @@ -3009,16 +3675,8 @@ impl Wallet { .expect("invalid issuedSupply"); if let Some(details) = &details { - if details.is_empty() { - return Err(Error::InvalidDetails { - details: s!("ident must contain at least one character"), - }); - } - let details = Details::from_str(details).map_err(|e| Error::InvalidDetails { - details: e.to_string(), - })?; builder = builder - .add_global_state("details", details) + .add_global_state("details", self._check_details(details.clone())?) .expect("invalid details"); }; @@ -3049,19 +3707,12 @@ impl Wallet { .import_contract(validated_contract, &mut self._blockchain_resolver()?) .expect("failure importing issued contract"); - if let Some(fp) = file_path { - let attachment_id = hex::encode(media.unwrap().digest); - let media_dir = self - .wallet_dir - .join(ASSETS_DIR) - .join(asset_id.clone()) - .join(attachment_id); - fs::create_dir_all(&media_dir)?; - let media_path = media_dir.join(MEDIA_FNAME); - fs::copy(fp, media_path)?; - let mime = mime.unwrap(); - fs::write(media_dir.join(MIME_FNAME), mime)?; - } + let media_idx = if let Some(file_path) = file_path { + let (_, media) = media_data.unwrap(); + Some(self._copy_media_and_save(file_path, &media)?) + } else { + None + }; let asset = self._add_asset_to_db( asset_id.clone(), @@ -3073,6 +3724,7 @@ impl Wallet { precision, None, created_at, + media_idx, )?; let batch_transfer = DbBatchTransferActMod { status: ActiveValue::Set(TransferStatus::Settled), @@ -3107,23 +3759,75 @@ impl Wallet { self.database.set_coloring(db_coloring)?; } - let asset = AssetCFA::get_asset_details( - self, - &asset, - self.wallet_dir.join(ASSETS_DIR), - None, - None, - None, - None, - None, - )?; + let asset = AssetCFA::get_asset_details(self, &asset, None, None, None, None, None, None)?; self.update_backup_info(false)?; - info!(self.logger, "Issue asset RGB25 completed"); + info!(self.logger, "Issue asset CFA completed"); Ok(asset) } + fn _get_asset_token( + &self, + asset_idx: i32, + medias: &[DbMedia], + tokens: &[DbToken], + token_medias: &[DbTokenMedia], + ) -> Result, InternalError> { + Ok( + if let Some(db_token) = tokens.iter().find(|t| t.asset_idx == asset_idx) { + let mut media = None; + let mut attachments = HashMap::new(); + let media_dir = self._media_dir(); + token_medias + .iter() + .filter(|tm| tm.token_idx == db_token.idx) + .for_each(|tm| { + let db_media = medias.iter().find(|m| m.idx == tm.media_idx).unwrap(); + let media_tkn = Media::from_db_media(db_media, &media_dir); + if let Some(attachment_id) = tm.attachment_id { + attachments.insert(attachment_id, media_tkn); + } else { + media = Some(media_tkn); + } + }); + + Some(TokenLight { + index: db_token.index, + ticker: db_token.ticker.clone(), + name: db_token.name.clone(), + details: db_token.details.clone(), + embedded_media: db_token.embedded_media, + media, + attachments, + reserves: db_token.reserves, + }) + } else { + None + }, + ) + } + + fn _get_asset_medias( + &self, + media_idx: Option, + token: Option, + ) -> Result, Error> { + let mut asset_medias = vec![]; + if let Some(token) = token { + if let Some(token_media) = token.media { + asset_medias.push(token_media); + } + for (_, attachment_media) in token.attachments { + asset_medias.push(attachment_media); + } + } else if let Some(media_idx) = media_idx { + let db_media = self.database.get_media(media_idx)?.unwrap(); + asset_medias.push(Media::from_db_media(&db_media, self._media_dir())) + } + Ok(asset_medias) + } + /// List the known RGB assets. /// /// Providing an empty `filter_asset_schemas` will list assets for all schemas, otherwise only @@ -3134,7 +3838,7 @@ impl Wallet { pub fn list_assets(&self, mut filter_asset_schemas: Vec) -> Result { info!(self.logger, "Listing assets..."); if filter_asset_schemas.is_empty() { - filter_asset_schemas = vec![AssetSchema::Nia, AssetSchema::Cfa]; + filter_asset_schemas = AssetSchema::VALUES.to_vec() } let batch_transfers = Some(self.database.iter_batch_transfers()?); @@ -3142,9 +3846,11 @@ impl Wallet { let txos = Some(self.database.iter_txos()?); let asset_transfers = Some(self.database.iter_asset_transfers()?); let transfers = Some(self.database.iter_transfers()?); + let medias = Some(self.database.iter_media()?); let assets = self.database.iter_assets()?; let mut nia = None; + let mut uda = None; let mut cfa = None; for schema in filter_asset_schemas { match schema { @@ -3157,19 +3863,45 @@ impl Wallet { AssetNIA::get_asset_details( self, a, - self.wallet_dir.join(ASSETS_DIR), transfers.clone(), asset_transfers.clone(), batch_transfers.clone(), colorings.clone(), txos.clone(), + medias.clone(), ) }) .collect::, Error>>()?, ); } + AssetSchema::Uda => { + let tokens = self.database.iter_tokens()?; + let token_medias = self.database.iter_token_medias()?; + uda = Some( + assets + .iter() + .filter(|a| a.schema == schema) + .map(|a| { + AssetUDA::get_asset_details( + self, + a, + self._get_asset_token( + a.idx, + &medias.clone().unwrap(), + &tokens, + &token_medias, + )?, + transfers.clone(), + asset_transfers.clone(), + batch_transfers.clone(), + colorings.clone(), + txos.clone(), + ) + }) + .collect::, Error>>()?, + ); + } AssetSchema::Cfa => { - let assets_dir = self.wallet_dir.join(ASSETS_DIR); cfa = Some( assets .iter() @@ -3178,12 +3910,12 @@ impl Wallet { AssetCFA::get_asset_details( self, a, - assets_dir.clone(), transfers.clone(), asset_transfers.clone(), batch_transfers.clone(), colorings.clone(), txos.clone(), + medias.clone(), ) }) .collect::, Error>>()?, @@ -3193,7 +3925,7 @@ impl Wallet { } info!(self.logger, "List assets completed"); - Ok(Assets { nia, cfa }) + Ok(Assets { nia, uda, cfa }) } fn _sync_if_online(&self, online: Option) -> Result<(), Error> { @@ -3477,32 +4209,84 @@ impl Wallet { runtime: &mut RgbRuntime, asset_schema: &AssetSchema, contract_id: ContractId, - ) -> Result { + ) -> Result<(), Error> { let contract_iface = self._get_contract_iface(runtime, asset_schema, contract_id)?; let timestamp = self._get_asset_timestamp(&contract_iface)?; - let (name, precision, issued_supply, ticker, details) = match &asset_schema { - AssetSchema::Nia => { - let iface_nia = Rgb20::from(contract_iface.clone()); - let spec = iface_nia.spec(); - let ticker = spec.ticker().to_string(); - let name = spec.name().to_string(); - let details = spec.details().map(|d| d.to_string()); - let precision = spec.precision.into(); - let issued_supply = iface_nia.total_issued_supply().into(); - (name, precision, issued_supply, Some(ticker), details) - } - AssetSchema::Cfa => { - let iface_cfa = Rgb25::from(contract_iface.clone()); - let name = iface_cfa.name().to_string(); - let details = iface_cfa.details().map(|d| d.to_string()); - let precision = iface_cfa.precision().into(); - let issued_supply = iface_cfa.total_issued_supply().into(); - (name, precision, issued_supply, None, details) - } - }; + let (name, precision, issued_supply, ticker, details, media_idx, token) = + match &asset_schema { + AssetSchema::Nia => { + let iface_nia = Rgb20::from(contract_iface.clone()); + let spec = iface_nia.spec(); + let ticker = spec.ticker().to_string(); + let name = spec.name().to_string(); + let details = spec.details().map(|d| d.to_string()); + let precision = spec.precision.into(); + let issued_supply = iface_nia.total_issued_supply().into(); + let media_idx = if let Some(attachment) = iface_nia.contract_data().media { + Some(self._get_or_insert_media( + hex::encode(attachment.digest), + attachment.ty.to_string(), + )?) + } else { + None + }; + ( + name, + precision, + issued_supply, + Some(ticker), + details, + media_idx, + None, + ) + } + AssetSchema::Uda => { + let iface_uda = Rgb21::from(contract_iface.clone()); + let spec = iface_uda.spec(); + let ticker = spec.ticker().to_string(); + let name = spec.name().to_string(); + let details = spec.details().map(|d| d.to_string()); + let precision = spec.precision.into(); + let issued_supply = 1; + let token_full = self._get_uda_token(contract_iface.clone())?; + ( + name, + precision, + issued_supply, + Some(ticker), + details, + None, + token_full, + ) + } + AssetSchema::Cfa => { + let iface_cfa = Rgb25::from(contract_iface.clone()); + let name = iface_cfa.name().to_string(); + let details = iface_cfa.details().map(|d| d.to_string()); + let precision = iface_cfa.precision().into(); + let issued_supply = iface_cfa.total_issued_supply().into(); + let media_idx = if let Some(attachment) = iface_cfa.contract_data().media { + Some(self._get_or_insert_media( + hex::encode(attachment.digest), + attachment.ty.to_string(), + )?) + } else { + None + }; + ( + name, + precision, + issued_supply, + None, + details, + media_idx, + None, + ) + } + }; - self._add_asset_to_db( + let db_asset = self._add_asset_to_db( contract_id.to_string(), asset_schema, None, @@ -3512,11 +4296,38 @@ impl Wallet { precision, ticker, timestamp, + media_idx, )?; + if let Some(token) = token { + let db_token = DbTokenActMod { + asset_idx: ActiveValue::Set(db_asset.idx), + index: ActiveValue::Set(token.index), + ticker: ActiveValue::Set(token.ticker), + name: ActiveValue::Set(token.name), + details: ActiveValue::Set(token.details), + embedded_media: ActiveValue::Set(token.embedded_media.is_some()), + reserves: ActiveValue::Set(token.reserves.is_some()), + ..Default::default() + }; + let token_idx = self.database.set_token(db_token)?; + + if let Some(media) = &token.media { + self._save_token_media(token_idx, media.get_digest(), media.mime.clone(), None)?; + } + for (attachment_id, media) in token.attachments { + self._save_token_media( + token_idx, + media.get_digest(), + media.mime.clone(), + Some(attachment_id), + )?; + } + } + self.update_backup_info(false)?; - Ok(contract_iface) + Ok(()) } fn _wait_consignment( @@ -3592,10 +4403,7 @@ impl Wallet { let mut updated_batch_transfer: DbBatchTransferActMod = batch_transfer.clone().into(); // write consignment - let transfer_dir = self - .wallet_dir - .join(TRANSFER_DIR) - .join(recipient_id.clone()); + let transfer_dir = self._transfers_dir().join(&recipient_id); let consignment_path = transfer_dir.join(CONSIGNMENT_RCV_FILE); fs::create_dir_all(transfer_dir)?; let consignment_bytes = general_purpose::STANDARD @@ -3653,10 +4461,10 @@ impl Wallet { let asset_schema = AssetSchema::from_schema_id(schema_id)?; // add asset info to transfer if missing - let contract_iface = if asset_transfer.asset_id.is_none() { + if asset_transfer.asset_id.is_none() { // check if asset is known let exists_check = self.database.check_asset_exists(asset_id.clone()); - let contract_iface = if exists_check.is_err() { + if exists_check.is_err() { // unknown asset debug!(self.logger, "Registering contract..."); let mut minimal_contract = consignment.clone().into_contract(); @@ -3672,78 +4480,81 @@ impl Wallet { minimal_contract_validated, &mut self._blockchain_resolver()?, ) - .expect("failure importing issued contract"); + .expect("failure importing received contract"); debug!(self.logger, "Contract registered"); - self.save_new_asset(&mut runtime, &asset_schema, contract_id)? - } else { - self._get_contract_iface(&mut runtime, &asset_schema, contract_id)? - }; + let contract_iface = + self._get_contract_iface(&mut runtime, &asset_schema, contract_id)?; + + let mut attachments = vec![]; + match asset_schema { + AssetSchema::Nia => { + let iface_nia = Rgb20::from(contract_iface); + if let Some(attachment) = iface_nia.contract_data().media { + attachments.push(attachment) + } + } + AssetSchema::Uda => { + let uda_attachments = self._get_uda_attachments(contract_iface)?; + attachments.extend(uda_attachments) + } + AssetSchema::Cfa => { + let iface_cfa = Rgb25::from(contract_iface); + if let Some(attachment) = iface_cfa.contract_data().media { + attachments.push(attachment) + } + } + }; + for attachment in attachments { + let digest = hex::encode(attachment.digest); + let media_path = self._media_dir().join(&digest); + // download media only if file not already present + if !media_path.exists() { + let media_res = self + .rest_client + .clone() + .get_media(&proxy_url, digest.clone())?; + #[cfg(test)] + debug!(self.logger, "Media GET response: {:?}", media_res); + if let Some(media_res) = media_res.result { + let file_bytes = general_purpose::STANDARD + .decode(media_res) + .map_err(InternalError::from)?; + let file_hash: sha256::Hash = Sha256Hash::hash(&file_bytes[..]); + let actual_digest = hex::encode(file_hash.to_byte_array()); + if digest != actual_digest { + error!( + self.logger, + "Attached file has a different hash than the one in the contract" + ); + return self._refuse_consignment( + proxy_url, + recipient_id, + &mut updated_batch_transfer, + ); + } + fs::write(&media_path, file_bytes)?; + } else { + error!( + self.logger, + "Cannot find the media file but the contract defines one" + ); + return self._refuse_consignment( + proxy_url, + recipient_id, + &mut updated_batch_transfer, + ); + } + } + } + + self.save_new_asset(&mut runtime, &asset_schema, contract_id)?; + } let mut updated_asset_transfer: DbAssetTransferActMod = asset_transfer.clone().into(); updated_asset_transfer.asset_id = ActiveValue::Set(Some(asset_id.clone())); self.database .update_asset_transfer(&mut updated_asset_transfer)?; - - contract_iface - } else { - self._get_contract_iface(&mut runtime, &asset_schema, contract_id)? - }; - - let contract_data = match asset_schema { - AssetSchema::Nia => { - let iface_nia = Rgb20::from(contract_iface); - iface_nia.contract_data() - } - AssetSchema::Cfa => { - let iface_cfa = Rgb25::from(contract_iface); - iface_cfa.contract_data() - } - }; - if let Some(media) = contract_data.media { - let attachment_id = hex::encode(media.digest); - let media_res = self - .rest_client - .clone() - .get_media(&proxy_url, attachment_id.clone())?; - #[cfg(test)] - debug!(self.logger, "Media GET response: {:?}", media_res); - if let Some(media_res) = media_res.result { - let file_bytes = general_purpose::STANDARD - .decode(media_res) - .map_err(InternalError::from)?; - let file_hash: sha256::Hash = Sha256Hash::hash(&file_bytes[..]); - let real_attachment_id = hex::encode(file_hash.to_byte_array()); - if attachment_id != real_attachment_id { - error!( - self.logger, - "Attached file has a different hash than the one in the contract" - ); - return self._refuse_consignment( - proxy_url, - recipient_id, - &mut updated_batch_transfer, - ); - } - let media_dir = self - .wallet_dir - .join(ASSETS_DIR) - .join(asset_id.clone()) - .join(&attachment_id); - fs::create_dir_all(&media_dir)?; - fs::write(media_dir.join(MEDIA_FNAME), file_bytes)?; - fs::write(media_dir.join(MIME_FNAME), media.ty.to_string())?; - } else { - error!( - self.logger, - "Cannot find the media file but the contract defines one" - ); - return self._refuse_consignment( - proxy_url, - recipient_id, - &mut updated_batch_transfer, - ); - } } let mut amount = 0; @@ -3775,6 +4586,22 @@ impl Wallet { } }; } + for structured_assignment in assignment.as_structured() { + if let Assign::ConfidentialSeal { seal, .. } = structured_assignment { + if Some(*seal) == known_concealed { + amount = 1; + break 'outer; + } + } + if let Assign::Revealed { seal, .. } = structured_assignment { + if seal.txid == TxPtr::WitnessTx + && Some(seal.vout.into_u32()) == vout + { + amount = 1; + break 'outer; + } + }; + } } } } @@ -3887,7 +4714,7 @@ impl Wallet { { updated_batch_transfer.status = ActiveValue::Set(TransferStatus::Failed); } else if batch_transfer_transfers.iter().all(|t| t.ack == Some(true)) { - let transfer_dir = self.wallet_dir.join(TRANSFER_DIR).join( + let transfer_dir = self._transfers_dir().join( batch_transfer .txid .as_ref() @@ -3960,7 +4787,7 @@ impl Wallet { .recipient_id .expect("transfer should have a recipient ID"); debug!(self.logger, "Recipient ID: {recipient_id}"); - let transfer_dir = self.wallet_dir.join(TRANSFER_DIR).join(recipient_id); + let transfer_dir = self._transfers_dir().join(recipient_id); let consignment_path = transfer_dir.join(CONSIGNMENT_RCV_FILE); let bindle = Bindle::::load(consignment_path).map_err(InternalError::from)?; @@ -4287,13 +5114,16 @@ impl Wallet { let contract_id = ContractId::from_str(&asset_id).expect("invalid contract ID"); let mut asset_transition_builder = runtime.transition_builder(contract_id, iface.clone(), None::<&str>)?; + let assignment_id = asset_transition_builder + .assignments_type(&assignment_name) + .ok_or(InternalError::Unexpected)?; - let assignment_id = asset_transition_builder.assignments_type(&assignment_name); - let assignment_id = assignment_id.ok_or(InternalError::Unexpected)?; - - for (opout, _state) in + let mut uda_state = None; + for (opout, state) in runtime.state_for_outpoints(contract_id, prev_outputs.iter().copied())? { + // there can be only a single state when contract is UDA + uda_state = Some(state); asset_transition_builder = asset_transition_builder .add_input(opout) .map_err(InternalError::from)?; @@ -4341,9 +5171,22 @@ impl Wallet { }; beneficiaries.push(seal); - asset_transition_builder = asset_transition_builder - .add_raw_state(assignment_id, seal, TypedState::Amount(recipient.amount)) - .map_err(InternalError::from)?; + match transfer_info.asset_iface { + AssetIface::RGB20 | AssetIface::RGB25 => { + asset_transition_builder = asset_transition_builder + .add_raw_state( + assignment_id, + seal, + TypedState::Amount(recipient.amount), + ) + .map_err(InternalError::from)?; + } + AssetIface::RGB21 => { + asset_transition_builder = asset_transition_builder + .add_raw_state(assignment_id, seal, uda_state.clone().unwrap()) + .map_err(InternalError::from)?; + } + } } let transition = asset_transition_builder @@ -4352,11 +5195,11 @@ impl Wallet { all_transitions.insert(contract_id, transition); asset_beneficiaries.insert(asset_id.clone(), beneficiaries); - let asset_transfer_dir = transfer_dir.join(asset_id.clone()); + let asset_transfer_dir = transfer_dir.join(&asset_id); if asset_transfer_dir.is_dir() { - fs::remove_dir_all(asset_transfer_dir.clone())?; + fs::remove_dir_all(&asset_transfer_dir)?; } - fs::create_dir_all(asset_transfer_dir.clone())?; + fs::create_dir_all(&asset_transfer_dir)?; // save asset transfer data to file (for send_end) let serialized_info = @@ -4459,7 +5302,7 @@ impl Wallet { } for (asset_id, _transfer_info) in transfer_info_map { - let asset_transfer_dir = transfer_dir.join(asset_id.clone()); + let asset_transfer_dir = transfer_dir.join(&asset_id); let consignment_path = asset_transfer_dir.join(CONSIGNMENT_FILE); let contract_id = ContractId::from_str(&asset_id).expect("invalid contract ID"); let beneficiaries = asset_beneficiaries[&asset_id].clone(); @@ -4495,21 +5338,9 @@ impl Wallet { &self, recipients: &mut Vec, asset_transfer_dir: PathBuf, - asset_dir: Option, txid: String, + medias: Vec, ) -> Result<(), Error> { - let mut attachments = vec![]; - if let Some(ass_dir) = &asset_dir { - for fp in fs::read_dir(ass_dir)? { - let fpath = fp?.path(); - let file_path = fpath.join(MEDIA_FNAME); - let file_bytes = std::fs::read(file_path.clone())?; - let file_hash: sha256::Hash = Sha256Hash::hash(&file_bytes[..]); - let attachment_id = hex::encode(file_hash.to_byte_array()); - attachments.push((attachment_id, file_path)) - } - } - let consignment_path = asset_transfer_dir.join(CONSIGNMENT_FILE); for recipient in recipients { let recipient_id = recipient.recipient_id(); @@ -4549,17 +5380,18 @@ impl Wallet { } else if consignment_res.result.is_none() { continue; } else { - for attachment in attachments.clone() { + for media in &medias { let media_res = self.rest_client.clone().post_media( &proxy_url, - attachment.0, - attachment.1, + media.get_digest(), + &media.file_path, )?; debug!(self.logger, "Attachment POST response: {:?}", media_res); if let Some(_err) = media_res.error { return Err(InternalError::Unexpected)?; } } + transport_endpoint.used = true; found_valid = true; break; @@ -4747,10 +5579,7 @@ impl Wallet { } let mut hasher = DefaultHasher::new(); receive_ids.hash(&mut hasher); - let transfer_dir = self - .wallet_dir - .join(TRANSFER_DIR) - .join(hasher.finish().to_string()); + let transfer_dir = self._transfers_dir().join(hasher.finish().to_string()); if transfer_dir.exists() { fs::remove_dir_all(&transfer_dir)?; } @@ -4909,7 +5738,7 @@ impl Wallet { // rename transfer directory let txid = psbt.clone().extract_tx().txid().to_string(); - let new_transfer_dir = self.wallet_dir.join(TRANSFER_DIR).join(txid); + let new_transfer_dir = self._transfers_dir().join(txid); fs::rename(transfer_dir, new_transfer_dir)?; info!(self.logger, "Send (begin) completed"); @@ -4932,7 +5761,7 @@ impl Wallet { // save signed PSBT let psbt = BdkPsbt::from_str(&signed_psbt)?; let txid = psbt.clone().extract_tx().txid().to_string(); - let transfer_dir = self.wallet_dir.join(TRANSFER_DIR).join(txid.clone()); + let transfer_dir = self._transfers_dir().join(&txid); let psbt_out = transfer_dir.join(SIGNED_PSBT_FILE); fs::write(psbt_out, psbt.to_string())?; @@ -4944,6 +5773,9 @@ impl Wallet { let blank_allocations = info_contents.blank_allocations; let change_utxo_idx = info_contents.change_utxo_idx; let donation = info_contents.donation; + let mut medias = None; + let mut tokens = None; + let mut token_medias = None; let mut transfer_info_map: BTreeMap = BTreeMap::new(); for ass_transf_dir in fs::read_dir(transfer_dir)? { let asset_transfer_dir = ass_transf_dir?.path(); @@ -4960,19 +5792,30 @@ impl Wallet { .to_str() .expect("should be possible to convert path to a string") .to_string(); - - // post consignment(s) and optional media - let ass_dir = self.wallet_dir.join(ASSETS_DIR).join(asset_id.clone()); - let asset_dir = if ass_dir.is_dir() { - Some(ass_dir) - } else { - None + let asset = self.database.get_asset(asset_id.clone())?.unwrap(); + let token = match asset.schema { + AssetSchema::Uda => { + if medias.clone().is_none() { + medias = Some(self.database.iter_media()?); + tokens = Some(self.database.iter_tokens()?); + token_medias = Some(self.database.iter_token_medias()?); + } + self._get_asset_token( + asset.idx, + medias.as_ref().unwrap(), + tokens.as_ref().unwrap(), + token_medias.as_ref().unwrap(), + )? + } + AssetSchema::Nia | AssetSchema::Cfa => None, }; + + // post consignment(s) and optional media(s) self._post_transfer_data( &mut info_contents.recipients, asset_transfer_dir, - asset_dir, txid.clone(), + self._get_asset_medias(asset.media_idx, token)?, )?; transfer_info_map.insert(asset_id, info_contents.clone()); diff --git a/src/wallet/test/get_asset_metadata.rs b/src/wallet/test/get_asset_metadata.rs index e6370f0..31fbec0 100644 --- a/src/wallet/test/get_asset_metadata.rs +++ b/src/wallet/test/get_asset_metadata.rs @@ -45,6 +45,38 @@ fn success() { assert!((timestamp - nia_metadata.timestamp) < 30); let file_str = "README.md"; + let image_str = ["tests", "qrcode.png"].join(&MAIN_SEPARATOR.to_string()); + let asset_uda = test_issue_asset_uda( + &wallet, + &online, + Some(file_str.to_string()), + vec![image_str.to_string(), file_str.to_string()], + ); + let transfers = test_list_transfers(&wallet, Some(&asset_uda.asset_id)); + assert_eq!(transfers.len(), 1); + let issuance = transfers.first().unwrap(); + let timestamp = issuance.created_at; + let uda_metadata = test_get_asset_metadata(&wallet, &asset_uda.asset_id); + + assert_eq!(uda_metadata.asset_iface, AssetIface::RGB21); + assert_eq!(uda_metadata.asset_schema, AssetSchema::Uda); + assert_eq!(uda_metadata.issued_supply, 1); + assert_eq!(uda_metadata.name, NAME.to_string()); + assert_eq!(uda_metadata.precision, PRECISION); + assert_eq!(uda_metadata.ticker, Some(TICKER.to_string())); + assert_eq!(uda_metadata.details, Some(DETAILS.to_string())); + assert!((timestamp - uda_metadata.timestamp) < 30); + let token = uda_metadata.token.unwrap(); + assert_eq!(token.index, 0); + assert!(token.ticker.is_none()); + assert!(token.name.is_none()); + assert!(token.details.is_none()); + assert!(token.embedded_media.is_none()); + assert_eq!(token.media.as_ref().unwrap().mime, "text/plain"); + assert_eq!(token.attachments.get(&0).unwrap().mime, "image/png"); + assert_eq!(token.attachments.get(&1).unwrap().mime, "text/plain"); + assert!(token.reserves.is_none()); + let details = None; let asset_cfa = wallet .issue_asset_cfa( diff --git a/src/wallet/test/issue_asset_cfa.rs b/src/wallet/test/issue_asset_cfa.rs index ed894cd..9443ff0 100644 --- a/src/wallet/test/issue_asset_cfa.rs +++ b/src/wallet/test/issue_asset_cfa.rs @@ -34,6 +34,7 @@ fn success() { assert_eq!(asset_1.name, NAME.to_string()); assert_eq!(asset_1.details, None); assert_eq!(asset_1.precision, PRECISION); + assert_eq!(asset_1.issued_supply, AMOUNT * 2); assert_eq!( asset_1.balance, Balance { @@ -66,23 +67,18 @@ fn success() { } ); assert!(asset_2.media.is_some()); - // check attached file contents match + // check media file contents match let media = asset_2.media.unwrap(); assert_eq!(media.mime, "text/plain"); let dst_path = media.file_path.clone(); let src_bytes = std::fs::read(PathBuf::from(file_str)).unwrap(); let dst_bytes = std::fs::read(PathBuf::from(dst_path.clone())).unwrap(); assert_eq!(src_bytes, dst_bytes); - // check attachment id for provided file matches + // check digest for provided file matches let src_hash: sha256::Hash = Sha256Hash::hash(&src_bytes[..]); - let src_attachment_id = src_hash.to_string(); - let dst_attachment_id = Path::new(&dst_path) - .parent() - .unwrap() - .file_name() - .unwrap() - .to_string_lossy(); - assert_eq!(src_attachment_id, dst_attachment_id); + let src_digest = src_hash.to_string(); + let dst_digest = Path::new(&dst_path).file_name().unwrap().to_string_lossy(); + assert_eq!(src_digest, dst_digest); // include an image file println!("\nasset 3"); @@ -102,23 +98,18 @@ fn success() { } ); assert!(asset_3.media.is_some()); - // check attached file contents match + // check media file contents match let media = asset_3.media.unwrap(); assert_eq!(media.mime, "image/png"); let dst_path = media.file_path.clone(); let src_bytes = std::fs::read(PathBuf::from(image_str)).unwrap(); let dst_bytes = std::fs::read(PathBuf::from(dst_path.clone())).unwrap(); assert_eq!(src_bytes, dst_bytes); - // check attachment id for provided file matches + // check digest for provided file matches let src_hash: sha256::Hash = Sha256Hash::hash(&src_bytes[..]); - let src_attachment_id = src_hash.to_string(); - let dst_attachment_id = Path::new(&dst_path) - .parent() - .unwrap() - .file_name() - .unwrap() - .to_string_lossy(); - assert_eq!(src_attachment_id, dst_attachment_id); + let src_digest = src_hash.to_string(); + let dst_digest = Path::new(&dst_path).file_name().unwrap().to_string_lossy(); + assert_eq!(src_digest, dst_digest); } #[test] @@ -159,7 +150,7 @@ fn multi_success() { && u.rgb_allocations.first().unwrap().asset_id == Some(asset.asset_id.clone()) })); - // check the allocated asset has one attachment + // check the allocated asset has one media let cfa_asset_list = test_list_assets(&wallet, &[]).cfa.unwrap(); let cfa_asset = cfa_asset_list .into_iter() diff --git a/src/wallet/test/issue_asset_nia.rs b/src/wallet/test/issue_asset_nia.rs index e4b1829..4051774 100644 --- a/src/wallet/test/issue_asset_nia.rs +++ b/src/wallet/test/issue_asset_nia.rs @@ -21,6 +21,7 @@ fn success() { assert_eq!(asset.name, NAME.to_string()); assert_eq!(asset.details, None); assert_eq!(asset.precision, PRECISION); + assert_eq!(asset.issued_supply, AMOUNT * 2); assert_eq!( asset.balance, Balance { diff --git a/src/wallet/test/issue_asset_uda.rs b/src/wallet/test/issue_asset_uda.rs new file mode 100644 index 0000000..7a41010 --- /dev/null +++ b/src/wallet/test/issue_asset_uda.rs @@ -0,0 +1,117 @@ +use super::*; +use serial_test::parallel; + +#[test] +#[parallel] +fn success() { + initialize(); + + let settled = 1; + let file_str = "README.md"; + let image_str = ["tests", "qrcode.png"].join(&MAIN_SEPARATOR.to_string()); + + let (wallet, online) = get_funded_wallet!(); + + // add a pending operation to an UTXO so spendable balance will be != settled / future + let _receive_data = test_blind_receive(&wallet); + + // required fields only + println!("\nasset 1"); + let before_timestamp = now().unix_timestamp(); + let bak_info_before = wallet.database.get_backup_info().unwrap().unwrap(); + let asset_1 = wallet + .issue_asset_uda( + online.clone(), + TICKER.to_string(), + NAME.to_string(), + None, + PRECISION, + None, + vec![], + ) + .unwrap(); + let bak_info_after = wallet.database.get_backup_info().unwrap().unwrap(); + assert!(bak_info_after.last_operation_timestamp > bak_info_before.last_operation_timestamp); + show_unspent_colorings(&wallet, "after issuance 1"); + assert_eq!(asset_1.ticker, TICKER.to_string()); + assert_eq!(asset_1.name, NAME.to_string()); + assert_eq!(asset_1.details, None); + assert_eq!(asset_1.precision, PRECISION); + assert_eq!(asset_1.issued_supply, settled); + assert_eq!( + asset_1.balance, + Balance { + settled, + future: settled, + spendable: settled, + } + ); + let token = asset_1.token.unwrap(); + assert_eq!(token.index, 0); + assert_eq!(token.ticker, None); + assert_eq!(token.name, None); + assert_eq!(token.details, None); + assert!(!token.embedded_media); + assert_eq!(token.media, None); + assert_eq!(token.attachments, HashMap::new()); + assert!(!token.reserves); + assert!(before_timestamp <= asset_1.added_at && asset_1.added_at <= now().unix_timestamp()); + + // include a token with a media and 2 attachments + println!("\nasset 2"); + let asset_2 = test_issue_asset_uda( + &wallet, + &online, + Some(file_str.to_string()), + vec![image_str.to_string(), file_str.to_string()], + ); + show_unspent_colorings(&wallet, "after issuance 2"); + assert_eq!(asset_2.ticker, TICKER.to_string()); + assert_eq!(asset_2.name, NAME.to_string()); + assert_eq!(asset_2.details, Some(DETAILS.to_string())); + assert_eq!(asset_2.precision, PRECISION); + assert_eq!( + asset_2.balance, + Balance { + settled, + future: settled, + spendable: settled, + } + ); + let token = asset_2.token.unwrap(); + assert!(token.media.is_some()); + assert!(!token.attachments.is_empty()); + // check media file contents match + let media = token.media.unwrap(); + assert_eq!(media.mime, "text/plain"); + let dst_path = media.file_path.clone(); + let src_bytes = std::fs::read(PathBuf::from(file_str)).unwrap(); + let dst_bytes = std::fs::read(PathBuf::from(dst_path.clone())).unwrap(); + assert_eq!(src_bytes, dst_bytes); + // check digest for provided file matches + let src_hash: sha256::Hash = Sha256Hash::hash(&src_bytes[..]); + let src_digest = src_hash.to_string(); + let dst_digest = Path::new(&dst_path).file_name().unwrap().to_string_lossy(); + assert_eq!(src_digest, dst_digest); + // check attachments + let media = token.attachments.get(&0).unwrap(); + assert_eq!(media.mime, "image/png"); + let dst_path = media.file_path.clone(); + let src_bytes = std::fs::read(PathBuf::from(image_str)).unwrap(); + let dst_bytes = std::fs::read(PathBuf::from(dst_path.clone())).unwrap(); + assert_eq!(src_bytes, dst_bytes); + let src_hash: sha256::Hash = Sha256Hash::hash(&src_bytes[..]); + let src_digest = src_hash.to_string(); + let dst_digest = Path::new(&dst_path).file_name().unwrap().to_string_lossy(); + assert_eq!(src_digest, dst_digest); + let media = token.attachments.get(&1).unwrap(); + assert_eq!(media.mime, "text/plain"); + let dst_path = media.file_path.clone(); + let src_bytes = std::fs::read(PathBuf::from(file_str)).unwrap(); + let dst_bytes = std::fs::read(PathBuf::from(dst_path.clone())).unwrap(); + assert_eq!(src_bytes, dst_bytes); + let src_hash: sha256::Hash = Sha256Hash::hash(&src_bytes[..]); + let src_digest = src_hash.to_string(); + let dst_digest = Path::new(&dst_path).file_name().unwrap().to_string_lossy(); + assert_eq!(src_digest, dst_digest); +} diff --git a/src/wallet/test/mod.rs b/src/wallet/test/mod.rs index 9b1487d..bae7c8e 100644 --- a/src/wallet/test/mod.rs +++ b/src/wallet/test/mod.rs @@ -39,6 +39,7 @@ const IDENT_NOT_ASCII_MSG: &str = "identifier name contains non-ASCII character( const RESTORE_DIR_PARTS: [&str; 3] = ["tests", "tmp", "restored"]; const MAX_ALLOCATIONS_PER_UTXO: u32 = 5; const MIN_CONFIRMATIONS: u8 = 1; +const FAKE_TXID: &str = "e5a3e577309df31bd606f48049049d2e1e02b048206ba232944fcc053a176ccb:0"; static INIT: Once = Once::new(); @@ -85,16 +86,35 @@ lazy_static! { static ref MOCK_CONTRACT_DATA: Mutex> = Mutex::new(vec![]); } -pub fn mock_contract_data(terms: RicardianContract, media: Option) -> ContractData { +pub fn mock_contract_data( + wallet: &Wallet, + terms: RicardianContract, + media: Option, +) -> ContractData { let mut mock_reqs = MOCK_CONTRACT_DATA.lock().unwrap(); if mock_reqs.is_empty() { - ContractData { terms, media } + wallet._new_contract_data(terms, media) } else { let mocked_media = mock_reqs.pop(); - ContractData { - terms, - media: mocked_media, - } + wallet._new_contract_data(terms, mocked_media) + } +} + +lazy_static! { + static ref MOCK_TOKEN_DATA: Mutex> = Mutex::new(vec![]); +} + +pub fn mock_token_data( + wallet: &Wallet, + index: TokenIndex, + media_data: &Option<(Attachment, Media)>, + attachments: BTreeMap, +) -> TokenData { + let mut mock_reqs = MOCK_TOKEN_DATA.lock().unwrap(); + if mock_reqs.is_empty() { + wallet._new_token_data(index, media_data, attachments) + } else { + mock_reqs.pop().unwrap() } } @@ -115,6 +135,7 @@ mod get_btc_balance; mod go_online; mod issue_asset_cfa; mod issue_asset_nia; +mod issue_asset_uda; mod list_assets; mod list_transactions; mod list_transfers; diff --git a/src/wallet/test/refresh.rs b/src/wallet/test/refresh.rs index acc3676..0220895 100644 --- a/src/wallet/test/refresh.rs +++ b/src/wallet/test/refresh.rs @@ -1,4 +1,6 @@ use super::*; +use rgbstd::interface::rgb21::EmbeddedMedia as RgbEmbeddedMedia; +use rgbstd::stl::ProofOfReserves as RgbProofOfReserves; use serial_test::{parallel, serial}; #[test] @@ -318,16 +320,10 @@ fn nia_with_media() { }; MOCK_CONTRACT_DATA.lock().unwrap().push(media.clone()); let asset = test_issue_asset_nia(&wallet_1, &online_1, None); - let attachment_id = hex::encode(media.digest); - let media_dir = wallet_1 - .wallet_dir - .join(ASSETS_DIR) - .join(asset.asset_id.clone()) - .join(attachment_id); + let digest = hex::encode(media.digest); + let media_dir = wallet_1.wallet_dir.join(MEDIA_DIR); fs::create_dir_all(&media_dir).unwrap(); - let media_path = media_dir.join(MEDIA_FNAME); - fs::copy(fp, media_path).unwrap(); - fs::write(media_dir.join(MIME_FNAME), mime).unwrap(); + fs::copy(fp, media_dir.join(digest)).unwrap(); let receive_data = test_blind_receive(&wallet_2); let recipient_map = HashMap::from([( @@ -379,3 +375,120 @@ fn nia_with_media() { assert_eq!(rcv_transfer_data.status, TransferStatus::Settled); assert_eq!(transfer_data.status, TransferStatus::Settled); } + +#[test] +#[serial] +fn uda_with_preview_and_reserves() { + initialize(); + + let amount: u64 = 1; + + let (wallet_1, online_1) = get_funded_wallet!(); + let (wallet_2, online_2) = get_funded_wallet!(); + let (wallet_3, online_3) = get_funded_wallet!(); + + let index_int = 7; + let data = vec![1u8, 3u8, 9u8]; + let preview_ty = "text/plain"; + let preview = RgbEmbeddedMedia { + ty: MediaType::with(preview_ty), + data: Confined::try_from(data.clone()).unwrap(), + }; + let proof = vec![2u8, 4u8, 6u8, 10u8]; + let reserves = RgbProofOfReserves { + utxo: RgbOutpoint::from_str(FAKE_TXID).unwrap(), + proof: Confined::try_from(proof.clone()).unwrap(), + }; + let token_data = TokenData { + index: TokenIndex::from_inner(index_int), + ticker: Some(Ticker::try_from(TICKER).unwrap()), + name: Some(Name::try_from(NAME).unwrap()), + details: Some(Details::try_from(DETAILS).unwrap()), + preview: Some(preview), + media: None, + attachments: Confined::try_from(BTreeMap::new()).unwrap(), + reserves: Some(reserves), + }; + MOCK_TOKEN_DATA.lock().unwrap().push(token_data.clone()); + let asset = test_issue_asset_uda(&wallet_1, &online_1, None, vec![]); + + let receive_data = test_blind_receive(&wallet_2); + let recipient_map = HashMap::from([( + asset.asset_id.clone(), + vec![Recipient { + amount, + recipient_data: RecipientData::BlindedUTXO( + SecretSeal::from_str(&receive_data.recipient_id).unwrap(), + ), + transport_endpoints: TRANSPORT_ENDPOINTS.clone(), + }], + )]); + let txid = test_send(&wallet_1, &online_1, &recipient_map); + assert!(!txid.is_empty()); + + wallet_2.refresh(online_2.clone(), None, vec![]).unwrap(); + let assets_list = test_list_assets(&wallet_2, &[]); + assert!(assets_list.uda.unwrap()[0] + .token + .as_ref() + .unwrap() + .media + .is_none()); + wallet_1.refresh(online_1.clone(), None, vec![]).unwrap(); + mine(false); + wallet_2.refresh(online_2.clone(), None, vec![]).unwrap(); + wallet_1.refresh(online_1.clone(), None, vec![]).unwrap(); + + let receive_data = test_blind_receive(&wallet_3); + let recipient_map = HashMap::from([( + asset.asset_id.clone(), + vec![Recipient { + amount, + recipient_data: RecipientData::BlindedUTXO( + SecretSeal::from_str(&receive_data.recipient_id).unwrap(), + ), + transport_endpoints: TRANSPORT_ENDPOINTS.clone(), + }], + )]); + let txid = test_send(&wallet_2, &online_2, &recipient_map); + assert!(!txid.is_empty()); + + wallet_3.refresh(online_3.clone(), None, vec![]).unwrap(); + let assets_list = test_list_assets(&wallet_3, &[]); + let uda = assets_list.uda.unwrap(); + let token = uda[0].token.as_ref().unwrap(); + assert_eq!(token.index, index_int); + assert_eq!(token.ticker, Some(TICKER.to_string())); + assert_eq!(token.name, Some(NAME.to_string())); + assert_eq!(token.details, Some(DETAILS.to_string())); + assert!(token.embedded_media); + assert!(token.media.is_none()); + assert_eq!(token.attachments, HashMap::new()); + assert!(token.reserves); + wallet_2.refresh(online_2.clone(), None, vec![]).unwrap(); + mine(false); + wallet_3.refresh(online_3, None, vec![]).unwrap(); + wallet_2.refresh(online_2.clone(), None, vec![]).unwrap(); + let rcv_transfer = get_test_transfer_recipient(&wallet_3, &receive_data.recipient_id); + let (rcv_transfer_data, _) = get_test_transfer_data(&wallet_3, &rcv_transfer); + let (transfer, _, _) = get_test_transfer_sender(&wallet_2, &txid); + let (transfer_data, _) = get_test_transfer_data(&wallet_2, &transfer); + assert_eq!(rcv_transfer_data.status, TransferStatus::Settled); + assert_eq!(transfer_data.status, TransferStatus::Settled); + + let uda_metadata = test_get_asset_metadata(&wallet_3, &asset.asset_id); + assert_eq!(uda_metadata.asset_iface, AssetIface::RGB21); + assert_eq!(uda_metadata.asset_schema, AssetSchema::Uda); + assert_eq!(uda_metadata.issued_supply, 1); + assert_eq!(uda_metadata.name, NAME.to_string()); + assert_eq!(uda_metadata.precision, PRECISION); + assert_eq!(uda_metadata.ticker, Some(TICKER.to_string())); + assert_eq!(uda_metadata.details, Some(DETAILS.to_string())); + let token = uda_metadata.token.unwrap(); + let embedded_media = token.embedded_media.unwrap(); + assert_eq!(embedded_media.mime, preview_ty); + assert_eq!(embedded_media.data, data); + let reserves = token.reserves.unwrap(); + assert_eq!(reserves.utxo.to_string(), FAKE_TXID); + assert_eq!(reserves.proof, proof); +} diff --git a/src/wallet/test/send.rs b/src/wallet/test/send.rs index 2e7bdc7..6c9d9fe 100644 --- a/src/wallet/test/send.rs +++ b/src/wallet/test/send.rs @@ -996,7 +996,7 @@ fn send_received_success() { assert_eq!(change_allocation_a.amount, amount_1a - amount_2a); assert_eq!(change_allocation_b.amount, amount_1b - amount_2b); - // check RGB25 asset has the correct attachment after being received + // check RGB25 asset has the correct media after being received let cfa_assets = wallet_3 .list_assets(vec![AssetSchema::Cfa]) .unwrap() @@ -1009,14 +1009,176 @@ fn send_received_success() { let dst_bytes = std::fs::read(PathBuf::from(dst_path.clone())).unwrap(); assert_eq!(src_bytes, dst_bytes); let src_hash: sha256::Hash = Sha256Hash::hash(&src_bytes[..]); - let src_attachment_id = hex::encode(src_hash.to_byte_array()); - let dst_attachment_id = Path::new(&dst_path) - .parent() + let src_digest = hex::encode(src_hash.to_byte_array()); + let dst_digest = Path::new(&dst_path).file_name().unwrap().to_string_lossy(); + assert_eq!(src_digest, dst_digest); +} + +#[test] +#[parallel] +fn send_received_uda_success() { + initialize(); + + let amount_1: u64 = 1; + let file_str = "README.md"; + let image_str = ["tests", "qrcode.png"].join(&MAIN_SEPARATOR.to_string()); + + // wallets + let (wallet_1, online_1) = get_funded_wallet!(); + let (wallet_2, online_2) = get_funded_wallet!(); + let (wallet_3, online_3) = get_funded_wallet!(); + + // issue + let asset = test_issue_asset_uda( + &wallet_1, + &online_1, + Some(file_str.to_string()), + vec![image_str.to_string(), file_str.to_string()], + ); + assert!(wallet_1 + .database + .get_asset(asset.asset_id.clone()) + .unwrap() + .unwrap() + .media_idx + .is_none()); + + // + // 1st transfer: wallet 1 > wallet 2 + // + + // send + let receive_data_1 = test_blind_receive(&wallet_2); + let recipient_map = HashMap::from([( + asset.asset_id.clone(), + vec![Recipient { + recipient_data: RecipientData::BlindedUTXO( + SecretSeal::from_str(&receive_data_1.recipient_id).unwrap(), + ), + amount: amount_1, + transport_endpoints: TRANSPORT_ENDPOINTS.clone(), + }], + )]); + let txid_1 = test_send(&wallet_1, &online_1, &recipient_map); + assert!(!txid_1.is_empty()); + + // take transfers from WaitingCounterparty to Settled + wallet_2.refresh(online_2.clone(), None, vec![]).unwrap(); + test_refresh_asset(&wallet_1, &online_1, &asset.asset_id); + mine(false); + wallet_2.refresh(online_2.clone(), None, vec![]).unwrap(); + test_refresh_asset(&wallet_1, &online_1, &asset.asset_id); + + // transfer 1 checks + let (transfer_w1, _, _) = get_test_transfer_sender(&wallet_1, &txid_1); + let transfer_w2 = get_test_transfer_recipient(&wallet_2, &receive_data_1.recipient_id); + let (transfer_data_w1, _) = get_test_transfer_data(&wallet_1, &transfer_w1); + let (transfer_data_w2, _) = get_test_transfer_data(&wallet_2, &transfer_w2); + assert_eq!(transfer_w1.amount, amount_1.to_string()); + assert_eq!(transfer_w2.amount, amount_1.to_string()); + assert_eq!(transfer_data_w1.status, TransferStatus::Settled); + assert_eq!(transfer_data_w2.status, TransferStatus::Settled); + + // + // 2nd transfer: wallet 2 > wallet 3 + // + + // send + let receive_data_2 = test_witness_receive(&wallet_3); + let recipient_map = HashMap::from([( + asset.asset_id.clone(), + vec![Recipient { + recipient_data: RecipientData::WitnessData { + script_buf: ScriptBuf::from_hex(&receive_data_2.recipient_id).unwrap(), + amount_sat: 1000, + blinding: None, + }, + amount: amount_1, + transport_endpoints: TRANSPORT_ENDPOINTS.clone(), + }], + )]); + let txid_2 = test_send(&wallet_2, &online_2, &recipient_map); + assert!(!txid_2.is_empty()); + + // take transfers from WaitingCounterparty to Settled + wallet_3.refresh(online_3.clone(), None, vec![]).unwrap(); + assert!(wallet_3 + .database + .get_asset(asset.asset_id.clone()) .unwrap() - .file_name() .unwrap() - .to_string_lossy(); - assert_eq!(src_attachment_id, dst_attachment_id); + .media_idx + .is_none()); + test_refresh_asset(&wallet_2, &online_2, &asset.asset_id); + mine(false); + wallet_3.refresh(online_3, None, vec![]).unwrap(); + test_refresh_asset(&wallet_2, &online_2, &asset.asset_id); + + // transfer 2 checks + let transfer_w3 = get_test_transfer_recipient(&wallet_3, &receive_data_2.recipient_id); + let (transfer_w2, _, _) = get_test_transfer_sender(&wallet_2, &txid_2); + let (transfer_data_w3, _) = get_test_transfer_data(&wallet_3, &transfer_w3); + let (transfer_data_w2, _) = get_test_transfer_data(&wallet_2, &transfer_w2); + assert_eq!(transfer_w3.amount, amount_1.to_string()); + assert_eq!(transfer_w2.amount, amount_1.to_string()); + assert_eq!(transfer_data_w3.status, TransferStatus::Settled); + assert_eq!(transfer_data_w2.status, TransferStatus::Settled); + + // check asset has been received correctly + let uda_assets = wallet_3 + .list_assets(vec![AssetSchema::Uda]) + .unwrap() + .uda + .unwrap(); + assert_eq!(uda_assets.len(), 1); + let recv_asset = uda_assets.first().unwrap(); + assert_eq!(recv_asset.asset_id, asset.asset_id); + assert_eq!(recv_asset.name, NAME.to_string()); + assert_eq!(recv_asset.details, Some(DETAILS.to_string())); + assert_eq!(recv_asset.precision, PRECISION); + assert_eq!( + recv_asset.balance, + Balance { + settled: amount_1, + future: amount_1, + spendable: amount_1, + } + ); + let token = recv_asset.token.as_ref().unwrap(); + // check media mime-type + let media = token.media.as_ref().unwrap(); + assert_eq!(media.mime, "text/plain"); + // check media data matches + let dst_path = media.file_path.clone(); + let src_bytes = std::fs::read(PathBuf::from(file_str)).unwrap(); + let dst_bytes = std::fs::read(PathBuf::from(dst_path.clone())).unwrap(); + assert_eq!(src_bytes, dst_bytes); + // check digest for provided file matches + let src_hash: sha256::Hash = Sha256Hash::hash(&src_bytes[..]); + let src_digest = hex::encode(src_hash.to_byte_array()); + let dst_digest = Path::new(&dst_path).file_name().unwrap().to_string_lossy(); + assert_eq!(src_digest, dst_digest); + // check attachments + let media = token.attachments.get(&0).unwrap(); + assert_eq!(media.mime, "image/png"); + let dst_path = media.file_path.clone(); + let src_bytes = std::fs::read(PathBuf::from(image_str)).unwrap(); + let dst_bytes = std::fs::read(PathBuf::from(dst_path.clone())).unwrap(); + assert_eq!(src_bytes, dst_bytes); + let src_hash: sha256::Hash = Sha256Hash::hash(&src_bytes[..]); + let src_digest = src_hash.to_string(); + let dst_digest = Path::new(&dst_path).file_name().unwrap().to_string_lossy(); + assert_eq!(src_digest, dst_digest); + let media = token.attachments.get(&1).unwrap(); + assert_eq!(media.mime, "text/plain"); + let dst_path = media.file_path.clone(); + let src_bytes = std::fs::read(PathBuf::from(file_str)).unwrap(); + let dst_bytes = std::fs::read(PathBuf::from(dst_path.clone())).unwrap(); + assert_eq!(src_bytes, dst_bytes); + let src_hash: sha256::Hash = Sha256Hash::hash(&src_bytes[..]); + let src_digest = src_hash.to_string(); + let dst_digest = Path::new(&dst_path).file_name().unwrap().to_string_lossy(); + assert_eq!(src_digest, dst_digest); } #[test] @@ -1151,24 +1313,19 @@ fn send_received_cfa_success() { spendable: amount_2, } ); - // check attachment mime-type + // check media mime-type let media = recv_asset.media.as_ref().unwrap(); assert_eq!(media.mime, "text/plain"); - // check attachment data matches + // check media data matches let dst_path = media.file_path.clone(); let src_bytes = std::fs::read(PathBuf::from(file_str)).unwrap(); let dst_bytes = std::fs::read(PathBuf::from(dst_path.clone())).unwrap(); assert_eq!(src_bytes, dst_bytes); - // check attachment id for provided file matches + // check digest for provided file matches let src_hash: sha256::Hash = Sha256Hash::hash(&src_bytes[..]); - let src_attachment_id = hex::encode(src_hash.to_byte_array()); - let dst_attachment_id = Path::new(&dst_path) - .parent() - .unwrap() - .file_name() - .unwrap() - .to_string_lossy(); - assert_eq!(src_attachment_id, dst_attachment_id); + let src_digest = hex::encode(src_hash.to_byte_array()); + let dst_digest = Path::new(&dst_path).file_name().unwrap().to_string_lossy(); + assert_eq!(src_digest, dst_digest); } #[test] diff --git a/src/wallet/test/utils/api.rs b/src/wallet/test/utils/api.rs index 91aac1d..d8cc491 100644 --- a/src/wallet/test/utils/api.rs +++ b/src/wallet/test/utils/api.rs @@ -225,6 +225,32 @@ pub(crate) fn test_go_online_result( wallet.go_online(skip_consistency_check, electrum) } +pub(crate) fn test_issue_asset_uda( + wallet: &Wallet, + online: &Online, + media_file_path: Option, + attachments_file_paths: Vec, +) -> AssetUDA { + test_issue_asset_uda_result(wallet, online, media_file_path, attachments_file_paths).unwrap() +} + +pub(crate) fn test_issue_asset_uda_result( + wallet: &Wallet, + online: &Online, + media_file_path: Option, + attachments_file_paths: Vec, +) -> Result { + wallet.issue_asset_uda( + online.clone(), + TICKER.to_string(), + NAME.to_string(), + Some(DETAILS.to_string()), + PRECISION, + media_file_path, + attachments_file_paths, + ) +} + pub(crate) fn test_issue_asset_cfa( wallet: &Wallet, online: &Online,